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,372 @@
/**
* @zh RPC 服务端模块
* @en RPC Server Module
*/
import { WebSocketServer, WebSocket } from 'ws'
import type {
ProtocolDef,
ApiNames,
MsgNames,
ApiInput,
ApiOutput,
MsgData,
Packet,
PacketType,
Connection,
} from '../types'
import { RpcError, ErrorCode } from '../types'
import { json } from '../codec/json'
import type { Codec } from '../codec/types'
import { ServerConnection } from './connection'
// ============ Types ============
/**
* @zh API 处理函数
* @en API handler function
*/
type ApiHandler<TInput, TOutput, TConnData> = (
input: TInput,
conn: Connection<TConnData>
) => TOutput | Promise<TOutput>
/**
* @zh 消息处理函数
* @en Message handler function
*/
type MsgHandler<TData, TConnData> = (
data: TData,
conn: Connection<TConnData>
) => void | Promise<void>
/**
* @zh API 处理器映射
* @en API handlers map
*/
type ApiHandlers<P extends ProtocolDef, TConnData> = {
[K in ApiNames<P>]: ApiHandler<
ApiInput<P['api'][K]>,
ApiOutput<P['api'][K]>,
TConnData
>
}
/**
* @zh 消息处理器映射
* @en Message handlers map
*/
type MsgHandlers<P extends ProtocolDef, TConnData> = {
[K in MsgNames<P>]?: MsgHandler<MsgData<P['msg'][K]>, TConnData>
}
/**
* @zh 服务器配置
* @en Server options
*/
export interface ServeOptions<P extends ProtocolDef, TConnData = unknown> {
/**
* @zh 监听端口
* @en Listen port
*/
port: number
/**
* @zh API 处理器
* @en API handlers
*/
api: ApiHandlers<P, TConnData>
/**
* @zh 消息处理器
* @en Message handlers
*/
msg?: MsgHandlers<P, TConnData>
/**
* @zh 编解码器
* @en Codec
* @defaultValue json()
*/
codec?: Codec
/**
* @zh 连接初始数据工厂
* @en Connection initial data factory
*/
createConnData?: () => TConnData
/**
* @zh 连接建立回调
* @en Connection established callback
*/
onConnect?: (conn: Connection<TConnData>) => void | Promise<void>
/**
* @zh 连接断开回调
* @en Connection closed callback
*/
onDisconnect?: (conn: Connection<TConnData>, reason?: string) => void | Promise<void>
/**
* @zh 错误回调
* @en Error callback
*/
onError?: (error: Error, conn?: Connection<TConnData>) => void
/**
* @zh 服务器启动回调
* @en Server started callback
*/
onStart?: (port: number) => void
}
/**
* @zh RPC 服务器实例
* @en RPC Server instance
*/
export interface RpcServer<P extends ProtocolDef, TConnData = unknown> {
/**
* @zh 启动服务器
* @en Start server
*/
start(): Promise<void>
/**
* @zh 停止服务器
* @en Stop server
*/
stop(): Promise<void>
/**
* @zh 获取所有连接
* @en Get all connections
*/
readonly connections: ReadonlyArray<Connection<TConnData>>
/**
* @zh 向单个连接发送消息
* @en Send message to a single connection
*/
send<K extends MsgNames<P>>(
conn: Connection<TConnData>,
name: K,
data: MsgData<P['msg'][K]>
): void
/**
* @zh 广播消息给所有连接
* @en Broadcast message to all connections
*/
broadcast<K extends MsgNames<P>>(
name: K,
data: MsgData<P['msg'][K]>,
options?: { exclude?: Connection<TConnData> | Connection<TConnData>[] }
): void
}
// ============ Implementation ============
const PT = {
ApiRequest: 0,
ApiResponse: 1,
ApiError: 2,
Message: 3,
Heartbeat: 9,
} as const
/**
* @zh 创建 RPC 服务器
* @en Create RPC server
*
* @example
* ```typescript
* const server = serve(protocol, {
* port: 3000,
* api: {
* join: async (input, conn) => {
* return { id: conn.id }
* },
* },
* })
* await server.start()
* ```
*/
export function serve<P extends ProtocolDef, TConnData = unknown>(
_protocol: P,
options: ServeOptions<P, TConnData>
): RpcServer<P, TConnData> {
const codec = options.codec ?? json()
const connections: ServerConnection<TConnData>[] = []
let wss: WebSocketServer | null = null
let connIdCounter = 0
const getClientIp = (ws: WebSocket, req: any): string => {
return req?.headers?.['x-forwarded-for']?.split(',')[0]?.trim()
|| req?.socket?.remoteAddress
|| 'unknown'
}
const handleMessage = async (
conn: ServerConnection<TConnData>,
data: string | Buffer
): Promise<void> => {
try {
const packet = codec.decode(
typeof data === 'string' ? data : new Uint8Array(data)
)
const type = packet[0]
if (type === PT.ApiRequest) {
const [, id, path, input] = packet as [number, number, string, unknown]
await handleApiRequest(conn, id, path, input)
} else if (type === PT.Message) {
const [, path, msgData] = packet as [number, string, unknown]
await handleMsg(conn, path, msgData)
} else if (type === PT.Heartbeat) {
conn.send(codec.encode([PT.Heartbeat]))
}
} catch (err) {
options.onError?.(err as Error, conn)
}
}
const handleApiRequest = async (
conn: ServerConnection<TConnData>,
id: number,
path: string,
input: unknown
): Promise<void> => {
const handler = (options.api as any)[path]
if (!handler) {
const errPacket: Packet = [PT.ApiError, id, ErrorCode.NOT_FOUND, `API not found: ${path}`]
conn.send(codec.encode(errPacket))
return
}
try {
const result = await handler(input, conn)
const resPacket: Packet = [PT.ApiResponse, id, result]
conn.send(codec.encode(resPacket))
} catch (err) {
if (err instanceof RpcError) {
const errPacket: Packet = [PT.ApiError, id, err.code, err.message]
conn.send(codec.encode(errPacket))
} else {
const errPacket: Packet = [PT.ApiError, id, ErrorCode.INTERNAL_ERROR, 'Internal server error']
conn.send(codec.encode(errPacket))
options.onError?.(err as Error, conn)
}
}
}
const handleMsg = async (
conn: ServerConnection<TConnData>,
path: string,
data: unknown
): Promise<void> => {
const handler = options.msg?.[path as MsgNames<P>]
if (handler) {
await (handler as any)(data, conn)
}
}
const server: RpcServer<P, TConnData> = {
get connections() {
return connections as ReadonlyArray<Connection<TConnData>>
},
async start() {
return new Promise((resolve) => {
wss = new WebSocketServer({ port: options.port })
wss.on('connection', async (ws, req) => {
const id = String(++connIdCounter)
const ip = getClientIp(ws, req)
const initialData = options.createConnData?.() ?? ({} as TConnData)
const conn = new ServerConnection<TConnData>({
id,
ip,
socket: ws,
initialData,
onClose: () => {
const idx = connections.indexOf(conn)
if (idx !== -1) connections.splice(idx, 1)
},
})
connections.push(conn)
ws.on('message', (data) => {
handleMessage(conn, data as string | Buffer)
})
ws.on('close', async (code, reason) => {
conn._markClosed()
const idx = connections.indexOf(conn)
if (idx !== -1) connections.splice(idx, 1)
await options.onDisconnect?.(conn, reason?.toString())
})
ws.on('error', (err) => {
options.onError?.(err, conn)
})
await options.onConnect?.(conn)
})
wss.on('listening', () => {
options.onStart?.(options.port)
resolve()
})
})
},
async stop() {
return new Promise((resolve, reject) => {
if (!wss) {
resolve()
return
}
for (const conn of connections) {
conn.close('Server shutting down')
}
wss.close((err) => {
if (err) reject(err)
else resolve()
})
})
},
send(conn, name, data) {
const packet: Packet = [PT.Message, name as string, data]
;(conn as ServerConnection<TConnData>).send(codec.encode(packet))
},
broadcast(name, data, opts) {
const packet: Packet = [PT.Message, name as string, data]
const encoded = codec.encode(packet)
const excludeSet = new Set(
Array.isArray(opts?.exclude)
? opts.exclude
: opts?.exclude
? [opts.exclude]
: []
)
for (const conn of connections) {
if (!excludeSet.has(conn)) {
conn.send(encoded)
}
}
},
}
return server
}