* feat(rpc,network): 新增 RPC 库并迁移网络模块 ## @esengine/rpc (新增) - 新增类型安全的 RPC 库,支持 WebSocket 通信 - 新增 RpcClient 类:connect/disconnect, call/send/on/off/once 方法 - 新增 RpcServer 类:Node.js WebSocket 服务端 - 新增编解码系统:支持 JSON 和 MessagePack - 新增 TextEncoder/TextDecoder polyfill,兼容微信小游戏平台 - 新增 WebSocketAdapter 接口,支持跨平台 WebSocket 抽象 ## @esengine/network (重构) - 重构 NetworkService:拆分为 RpcService 基类和 GameNetworkService - 新增 gameProtocol:类型安全的 API 和消息定义 - 新增类型安全便捷方法:sendInput(), onSync(), onSpawn(), onDespawn() - 更新 NetworkPlugin 使用新的服务架构 - 移除 TSRPC 依赖,改用 @esengine/rpc ## 文档 - 新增 RPC 模块文档(中英文) - 更新 Network 模块文档(中英文) - 更新侧边栏导航 * fix(network,cli): 修复 CI 构建和更新 CLI 适配器 ## 修复 - 在 tsconfig.build.json 添加 rpc 引用,修复类型声明生成 ## CLI 更新 - 更新 nodejs 适配器使用新的 @esengine/rpc - 生成的服务器代码使用 RpcServer 替代旧的 GameServer - 添加 ws 和 @types/ws 依赖 - 更新 README 模板中的客户端连接示例 * chore: 添加 CLI changeset * fix(ci): add @esengine/rpc to build and check scripts - Add rpc package to CI build step (must build before network) - Add rpc to type-check:framework, lint:framework, test:ci:framework * fix(rpc,network): fix tsconfig for declaration generation - Remove composite mode from rpc (not needed, causes CI issues) - Remove rpc from network project references (resolves via node_modules) - Remove unused references from network tsconfig.build.json
462 lines
13 KiB
TypeScript
462 lines
13 KiB
TypeScript
/**
|
|
* @zh RPC 客户端模块
|
|
* @en RPC Client Module
|
|
*/
|
|
|
|
import type {
|
|
ProtocolDef,
|
|
ApiNames,
|
|
MsgNames,
|
|
ApiInput,
|
|
ApiOutput,
|
|
MsgData,
|
|
Packet,
|
|
ConnectionStatus,
|
|
} from '../types'
|
|
import { RpcError, ErrorCode } from '../types'
|
|
import { json } from '../codec/json'
|
|
import type { Codec } from '../codec/types'
|
|
|
|
// ============================================================================
|
|
// Re-exports | 类型重导出
|
|
// ============================================================================
|
|
|
|
export type {
|
|
ProtocolDef,
|
|
ApiNames,
|
|
MsgNames,
|
|
ApiInput,
|
|
ApiOutput,
|
|
MsgData,
|
|
ConnectionStatus,
|
|
Codec,
|
|
}
|
|
export { RpcError, ErrorCode }
|
|
|
|
// ============================================================================
|
|
// Types | 类型定义
|
|
// ============================================================================
|
|
|
|
/**
|
|
* @zh WebSocket 适配器接口
|
|
* @en WebSocket adapter interface
|
|
*
|
|
* @zh 用于适配不同平台的 WebSocket 实现(浏览器、微信小游戏等)
|
|
* @en Used to adapt different platform WebSocket implementations (browser, WeChat Mini Games, etc.)
|
|
*/
|
|
export interface WebSocketAdapter {
|
|
readonly readyState: number
|
|
send(data: string | ArrayBuffer): void
|
|
close(code?: number, reason?: string): void
|
|
onopen: ((ev: Event) => void) | null
|
|
onclose: ((ev: { code: number; reason: string }) => void) | null
|
|
onerror: ((ev: Event) => void) | null
|
|
onmessage: ((ev: { data: string | ArrayBuffer }) => void) | null
|
|
}
|
|
|
|
/**
|
|
* @zh WebSocket 工厂函数类型
|
|
* @en WebSocket factory function type
|
|
*/
|
|
export type WebSocketFactory = (url: string) => WebSocketAdapter
|
|
|
|
/**
|
|
* @zh 客户端配置
|
|
* @en Client options
|
|
*/
|
|
export interface RpcClientOptions {
|
|
/**
|
|
* @zh 编解码器
|
|
* @en Codec
|
|
* @defaultValue json()
|
|
*/
|
|
codec?: Codec
|
|
|
|
/**
|
|
* @zh API 调用超时(毫秒)
|
|
* @en API call timeout in milliseconds
|
|
* @defaultValue 30000
|
|
*/
|
|
timeout?: number
|
|
|
|
/**
|
|
* @zh 自动重连
|
|
* @en Auto reconnect
|
|
* @defaultValue true
|
|
*/
|
|
autoReconnect?: boolean
|
|
|
|
/**
|
|
* @zh 重连间隔(毫秒)
|
|
* @en Reconnect interval in milliseconds
|
|
* @defaultValue 3000
|
|
*/
|
|
reconnectInterval?: number
|
|
|
|
/**
|
|
* @zh WebSocket 工厂函数
|
|
* @en WebSocket factory function
|
|
*
|
|
* @zh 用于自定义 WebSocket 实现,如微信小游戏
|
|
* @en Used for custom WebSocket implementation, e.g., WeChat Mini Games
|
|
*/
|
|
webSocketFactory?: WebSocketFactory
|
|
|
|
/**
|
|
* @zh 连接成功回调
|
|
* @en Connection established callback
|
|
*/
|
|
onConnect?: () => void
|
|
|
|
/**
|
|
* @zh 连接断开回调
|
|
* @en Connection closed callback
|
|
*/
|
|
onDisconnect?: (reason?: string) => void
|
|
|
|
/**
|
|
* @zh 错误回调
|
|
* @en Error callback
|
|
*/
|
|
onError?: (error: Error) => void
|
|
}
|
|
|
|
/** @deprecated Use RpcClientOptions instead */
|
|
export type ConnectOptions = RpcClientOptions
|
|
|
|
// ============================================================================
|
|
// Constants | 常量
|
|
// ============================================================================
|
|
|
|
const PacketType = {
|
|
ApiRequest: 0,
|
|
ApiResponse: 1,
|
|
ApiError: 2,
|
|
Message: 3,
|
|
Heartbeat: 9,
|
|
} as const
|
|
|
|
const defaultWebSocketFactory: WebSocketFactory = (url) =>
|
|
new WebSocket(url) as unknown as WebSocketAdapter
|
|
|
|
// ============================================================================
|
|
// RpcClient Class | RPC 客户端类
|
|
// ============================================================================
|
|
|
|
interface PendingCall {
|
|
resolve: (value: unknown) => void
|
|
reject: (error: Error) => void
|
|
timer: ReturnType<typeof setTimeout>
|
|
}
|
|
|
|
/**
|
|
* @zh RPC 客户端
|
|
* @en RPC Client
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const client = new RpcClient(protocol, 'ws://localhost:3000')
|
|
* await client.connect()
|
|
*
|
|
* const result = await client.call('join', { name: 'Alice' })
|
|
*
|
|
* client.on('chat', (msg) => console.log(msg.text))
|
|
* ```
|
|
*/
|
|
export class RpcClient<P extends ProtocolDef> {
|
|
private readonly _url: string
|
|
private readonly _codec: Codec
|
|
private readonly _timeout: number
|
|
private readonly _reconnectInterval: number
|
|
private readonly _wsFactory: WebSocketFactory
|
|
private readonly _options: RpcClientOptions
|
|
|
|
private _ws: WebSocketAdapter | null = null
|
|
private _status: ConnectionStatus = 'closed'
|
|
private _callIdCounter = 0
|
|
private _shouldReconnect: boolean
|
|
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
private readonly _pendingCalls = new Map<number, PendingCall>()
|
|
private readonly _msgHandlers = new Map<string, Set<(data: unknown) => void>>()
|
|
|
|
constructor(
|
|
_protocol: P,
|
|
url: string,
|
|
options: RpcClientOptions = {}
|
|
) {
|
|
this._url = url
|
|
this._options = options
|
|
this._codec = options.codec ?? json()
|
|
this._timeout = options.timeout ?? 30000
|
|
this._shouldReconnect = options.autoReconnect ?? true
|
|
this._reconnectInterval = options.reconnectInterval ?? 3000
|
|
this._wsFactory = options.webSocketFactory ?? defaultWebSocketFactory
|
|
}
|
|
|
|
/**
|
|
* @zh 连接状态
|
|
* @en Connection status
|
|
*/
|
|
get status(): ConnectionStatus {
|
|
return this._status
|
|
}
|
|
|
|
/**
|
|
* @zh 是否已连接
|
|
* @en Whether connected
|
|
*/
|
|
get isConnected(): boolean {
|
|
return this._status === 'open'
|
|
}
|
|
|
|
/**
|
|
* @zh 连接到服务器
|
|
* @en Connect to server
|
|
*/
|
|
connect(): Promise<this> {
|
|
return new Promise((resolve, reject) => {
|
|
if (this._status === 'open' || this._status === 'connecting') {
|
|
resolve(this)
|
|
return
|
|
}
|
|
|
|
this._status = 'connecting'
|
|
this._ws = this._wsFactory(this._url)
|
|
|
|
this._ws.onopen = () => {
|
|
this._status = 'open'
|
|
this._options.onConnect?.()
|
|
resolve(this)
|
|
}
|
|
|
|
this._ws.onclose = (e) => {
|
|
this._status = 'closed'
|
|
this._rejectAllPending()
|
|
this._options.onDisconnect?.(e.reason)
|
|
this._scheduleReconnect()
|
|
}
|
|
|
|
this._ws.onerror = () => {
|
|
const err = new Error('WebSocket error')
|
|
this._options.onError?.(err)
|
|
if (this._status === 'connecting') {
|
|
reject(err)
|
|
}
|
|
}
|
|
|
|
this._ws.onmessage = (e) => {
|
|
this._handleMessage(e.data as string | ArrayBuffer)
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @zh 断开连接
|
|
* @en Disconnect
|
|
*/
|
|
disconnect(): void {
|
|
this._shouldReconnect = false
|
|
this._clearReconnectTimer()
|
|
if (this._ws) {
|
|
this._status = 'closing'
|
|
this._ws.close()
|
|
this._ws = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @zh 调用 API
|
|
* @en Call API
|
|
*/
|
|
call<K extends ApiNames<P>>(
|
|
name: K,
|
|
input: ApiInput<P['api'][K]>
|
|
): Promise<ApiOutput<P['api'][K]>> {
|
|
return new Promise((resolve, reject) => {
|
|
if (this._status !== 'open') {
|
|
reject(new RpcError(ErrorCode.CONNECTION_CLOSED, 'Not connected'))
|
|
return
|
|
}
|
|
|
|
const id = ++this._callIdCounter
|
|
const timer = setTimeout(() => {
|
|
this._pendingCalls.delete(id)
|
|
reject(new RpcError(ErrorCode.TIMEOUT, 'Request timeout'))
|
|
}, this._timeout)
|
|
|
|
this._pendingCalls.set(id, {
|
|
resolve: resolve as (v: unknown) => void,
|
|
reject,
|
|
timer,
|
|
})
|
|
|
|
const packet: Packet = [PacketType.ApiRequest, id, name as string, input]
|
|
this._ws!.send(this._codec.encode(packet) as string | ArrayBuffer)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @zh 发送消息
|
|
* @en Send message
|
|
*/
|
|
send<K extends MsgNames<P>>(name: K, data: MsgData<P['msg'][K]>): void {
|
|
if (this._status !== 'open') return
|
|
const packet: Packet = [PacketType.Message, name as string, data]
|
|
this._ws!.send(this._codec.encode(packet) as string | ArrayBuffer)
|
|
}
|
|
|
|
/**
|
|
* @zh 监听消息
|
|
* @en Listen to message
|
|
*/
|
|
on<K extends MsgNames<P>>(
|
|
name: K,
|
|
handler: (data: MsgData<P['msg'][K]>) => void
|
|
): this {
|
|
const key = name as string
|
|
let handlers = this._msgHandlers.get(key)
|
|
if (!handlers) {
|
|
handlers = new Set()
|
|
this._msgHandlers.set(key, handlers)
|
|
}
|
|
handlers.add(handler as (data: unknown) => void)
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* @zh 取消监听消息
|
|
* @en Remove message listener
|
|
*/
|
|
off<K extends MsgNames<P>>(
|
|
name: K,
|
|
handler?: (data: MsgData<P['msg'][K]>) => void
|
|
): this {
|
|
const key = name as string
|
|
if (handler) {
|
|
this._msgHandlers.get(key)?.delete(handler as (data: unknown) => void)
|
|
} else {
|
|
this._msgHandlers.delete(key)
|
|
}
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* @zh 监听消息(只触发一次)
|
|
* @en Listen to message (once)
|
|
*/
|
|
once<K extends MsgNames<P>>(
|
|
name: K,
|
|
handler: (data: MsgData<P['msg'][K]>) => void
|
|
): this {
|
|
const wrapper = (data: MsgData<P['msg'][K]>) => {
|
|
this.off(name, wrapper)
|
|
handler(data)
|
|
}
|
|
return this.on(name, wrapper)
|
|
}
|
|
|
|
// ========================================================================
|
|
// Private Methods | 私有方法
|
|
// ========================================================================
|
|
|
|
private _handleMessage(raw: string | ArrayBuffer): void {
|
|
try {
|
|
const data = typeof raw === 'string' ? raw : new Uint8Array(raw)
|
|
const packet = this._codec.decode(data)
|
|
const type = packet[0]
|
|
|
|
switch (type) {
|
|
case PacketType.ApiResponse:
|
|
this._handleApiResponse(packet as [number, number, unknown])
|
|
break
|
|
case PacketType.ApiError:
|
|
this._handleApiError(packet as [number, number, string, string])
|
|
break
|
|
case PacketType.Message:
|
|
this._handleMsg(packet as [number, string, unknown])
|
|
break
|
|
}
|
|
} catch (err) {
|
|
this._options.onError?.(err as Error)
|
|
}
|
|
}
|
|
|
|
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 RpcError(code, message))
|
|
}
|
|
}
|
|
|
|
private _handleMsg([, path, data]: [number, string, unknown]): void {
|
|
const handlers = this._msgHandlers.get(path)
|
|
if (handlers) {
|
|
for (const handler of handlers) {
|
|
try {
|
|
handler(data)
|
|
} catch (err) {
|
|
this._options.onError?.(err as Error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private _rejectAllPending(): void {
|
|
for (const [, pending] of this._pendingCalls) {
|
|
clearTimeout(pending.timer)
|
|
pending.reject(new RpcError(ErrorCode.CONNECTION_CLOSED, 'Connection closed'))
|
|
}
|
|
this._pendingCalls.clear()
|
|
}
|
|
|
|
private _scheduleReconnect(): void {
|
|
if (this._shouldReconnect && !this._reconnectTimer) {
|
|
this._reconnectTimer = setTimeout(() => {
|
|
this._reconnectTimer = null
|
|
this.connect().catch(() => {})
|
|
}, this._reconnectInterval)
|
|
}
|
|
}
|
|
|
|
private _clearReconnectTimer(): void {
|
|
if (this._reconnectTimer) {
|
|
clearTimeout(this._reconnectTimer)
|
|
this._reconnectTimer = null
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Factory Function | 工厂函数
|
|
// ============================================================================
|
|
|
|
/**
|
|
* @zh 连接到 RPC 服务器(便捷函数)
|
|
* @en Connect to RPC server (convenience function)
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const client = await connect(protocol, 'ws://localhost:3000')
|
|
* const result = await client.call('join', { name: 'Alice' })
|
|
* ```
|
|
*/
|
|
export function connect<P extends ProtocolDef>(
|
|
protocol: P,
|
|
url: string,
|
|
options: RpcClientOptions = {}
|
|
): Promise<RpcClient<P>> {
|
|
return new RpcClient(protocol, url, options).connect()
|
|
}
|