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
This commit is contained in:
YHH
2025-12-28 10:54:51 +08:00
committed by GitHub
parent 8605888f11
commit 7940f581a6
39 changed files with 3505 additions and 784 deletions

View File

@@ -0,0 +1,461 @@
/**
* @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()
}