Files
esengine/packages/framework/rpc/src/client/index.ts

462 lines
13 KiB
TypeScript
Raw Normal View History

feat(rpc,network): 新增 RPC 库并迁移网络模块 (#364) * 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
2025-12-28 10:54:51 +08:00
/**
* @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()
}