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:
68
packages/framework/rpc/src/server/connection.ts
Normal file
68
packages/framework/rpc/src/server/connection.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @zh 服务端连接模块
|
||||
* @en Server Connection Module
|
||||
*/
|
||||
|
||||
import type { Connection, ConnectionStatus } from '../types'
|
||||
|
||||
/**
|
||||
* @zh 服务端连接实现
|
||||
* @en Server connection implementation
|
||||
*/
|
||||
export class ServerConnection<TData = unknown> implements Connection<TData> {
|
||||
readonly id: string
|
||||
readonly ip: string
|
||||
data: TData
|
||||
|
||||
private _status: ConnectionStatus = 'open'
|
||||
private _socket: any
|
||||
private _onClose?: () => void
|
||||
|
||||
constructor(options: {
|
||||
id: string
|
||||
ip: string
|
||||
socket: any
|
||||
initialData: TData
|
||||
onClose?: () => void
|
||||
}) {
|
||||
this.id = options.id
|
||||
this.ip = options.ip
|
||||
this.data = options.initialData
|
||||
this._socket = options.socket
|
||||
this._onClose = options.onClose
|
||||
}
|
||||
|
||||
get status(): ConnectionStatus {
|
||||
return this._status
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 发送原始数据
|
||||
* @en Send raw data
|
||||
*/
|
||||
send(data: string | Uint8Array): void {
|
||||
if (this._status !== 'open') return
|
||||
this._socket.send(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 关闭连接
|
||||
* @en Close connection
|
||||
*/
|
||||
close(reason?: string): void {
|
||||
if (this._status !== 'open') return
|
||||
|
||||
this._status = 'closing'
|
||||
this._socket.close(1000, reason)
|
||||
this._status = 'closed'
|
||||
this._onClose?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 标记连接已关闭(内部使用)
|
||||
* @en Mark connection as closed (internal use)
|
||||
*/
|
||||
_markClosed(): void {
|
||||
this._status = 'closed'
|
||||
}
|
||||
}
|
||||
372
packages/framework/rpc/src/server/index.ts
Normal file
372
packages/framework/rpc/src/server/index.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user