Implement WebSocket Gateway with NestJS and client SDK
- Add NestJS WebSocket Gateway with Socket.IO for real-time communication - Create client SDK (GatewayClient) supporting both browser and Node.js - Implement device registration and point-to-point message routing - Add action types: request/response (RPC), stream (for chat messages) - Integrate Pino logger for structured logging - Configure heartbeat detection (pingInterval/pingTimeout) - Use UUID v7 for time-ordered message IDs Gateway features: - Device registration with deviceId and deviceType (client/agent) - Message routing between devices via Gateway - HTTP API endpoints (/ping, /broadcast) - Auto-reconnect support in client SDK Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b36769f913
commit
7d94b40a11
19 changed files with 2237 additions and 5 deletions
14
src/shared/gateway-sdk/actions/hello.ts
Normal file
14
src/shared/gateway-sdk/actions/hello.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/** Hello Action - 测试用的问候消息 */
|
||||
|
||||
export const HelloAction = "hello" as const;
|
||||
export const HelloResponseAction = "hello_response" as const;
|
||||
|
||||
/** Hello 请求 payload */
|
||||
export interface HelloPayload {
|
||||
greeting: string;
|
||||
}
|
||||
|
||||
/** Hello 响应 payload */
|
||||
export interface HelloResponsePayload {
|
||||
reply: string;
|
||||
}
|
||||
19
src/shared/gateway-sdk/actions/index.ts
Normal file
19
src/shared/gateway-sdk/actions/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export {
|
||||
HelloAction,
|
||||
HelloResponseAction,
|
||||
type HelloPayload,
|
||||
type HelloResponsePayload,
|
||||
} from "./hello.js";
|
||||
|
||||
export {
|
||||
RequestAction,
|
||||
ResponseAction,
|
||||
type RequestPayload,
|
||||
type ResponsePayload,
|
||||
type ResponseSuccessPayload,
|
||||
type ResponseErrorPayload,
|
||||
isResponseSuccess,
|
||||
isResponseError,
|
||||
} from "./rpc.js";
|
||||
|
||||
export { StreamAction, type StreamPayload } from "./stream.js";
|
||||
55
src/shared/gateway-sdk/actions/rpc.ts
Normal file
55
src/shared/gateway-sdk/actions/rpc.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/** RPC Actions - 请求/响应模式 */
|
||||
|
||||
export const RequestAction = "request" as const;
|
||||
export const ResponseAction = "response" as const;
|
||||
|
||||
/** 请求帧 payload */
|
||||
export interface RequestPayload<T = unknown> {
|
||||
/** 调用的方法名 */
|
||||
method: string;
|
||||
/** 方法参数 */
|
||||
params?: T;
|
||||
}
|
||||
|
||||
/** 响应帧 payload - 成功 */
|
||||
export interface ResponseSuccessPayload<T = unknown> {
|
||||
/** 与请求消息 ID 匹配 */
|
||||
requestId: string;
|
||||
/** 是否成功 */
|
||||
ok: true;
|
||||
/** 返回数据 */
|
||||
payload: T;
|
||||
}
|
||||
|
||||
/** 响应帧 payload - 失败 */
|
||||
export interface ResponseErrorPayload {
|
||||
/** 与请求消息 ID 匹配 */
|
||||
requestId: string;
|
||||
/** 是否成功 */
|
||||
ok: false;
|
||||
/** 错误信息 */
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** 响应帧 payload(联合类型) */
|
||||
export type ResponsePayload<T = unknown> =
|
||||
| ResponseSuccessPayload<T>
|
||||
| ResponseErrorPayload;
|
||||
|
||||
/** 类型守卫:判断响应是否成功 */
|
||||
export function isResponseSuccess<T>(
|
||||
response: ResponsePayload<T>
|
||||
): response is ResponseSuccessPayload<T> {
|
||||
return response.ok === true;
|
||||
}
|
||||
|
||||
/** 类型守卫:判断响应是否失败 */
|
||||
export function isResponseError(
|
||||
response: ResponsePayload
|
||||
): response is ResponseErrorPayload {
|
||||
return response.ok === false;
|
||||
}
|
||||
11
src/shared/gateway-sdk/actions/stream.ts
Normal file
11
src/shared/gateway-sdk/actions/stream.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/** Stream Action - 流式消息传输 */
|
||||
|
||||
export const StreamAction = "stream" as const;
|
||||
|
||||
/** 流消息 payload */
|
||||
export interface StreamPayload<T = unknown> {
|
||||
/** 流 ID,用于关联同一个流的所有消息 */
|
||||
streamId: string;
|
||||
/** 数据 */
|
||||
data: T;
|
||||
}
|
||||
261
src/shared/gateway-sdk/client.ts
Normal file
261
src/shared/gateway-sdk/client.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { io, Socket } from "socket.io-client";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type {
|
||||
GatewayClientOptions,
|
||||
GatewayClientCallbacks,
|
||||
ConnectionState,
|
||||
RoutedMessage,
|
||||
RegisteredResponse,
|
||||
SendErrorResponse,
|
||||
PingPayload,
|
||||
DeviceType,
|
||||
} from "./types.js";
|
||||
import { GatewayEvents } from "./types.js";
|
||||
|
||||
interface ResolvedOptions {
|
||||
url: string;
|
||||
path: string;
|
||||
deviceId: string;
|
||||
deviceType: DeviceType;
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
autoReconnect: boolean;
|
||||
reconnectDelay: number;
|
||||
}
|
||||
|
||||
export class GatewayClient {
|
||||
private socket: Socket | null = null;
|
||||
private options: ResolvedOptions;
|
||||
private callbacks: GatewayClientCallbacks = {};
|
||||
private _state: ConnectionState = "disconnected";
|
||||
|
||||
constructor(options: GatewayClientOptions) {
|
||||
if (!options.deviceId) {
|
||||
throw new Error("deviceId is required");
|
||||
}
|
||||
|
||||
this.options = {
|
||||
url: options.url,
|
||||
path: options.path ?? "/ws",
|
||||
deviceId: options.deviceId,
|
||||
deviceType: options.deviceType,
|
||||
metadata: options.metadata,
|
||||
autoReconnect: options.autoReconnect ?? true,
|
||||
reconnectDelay: options.reconnectDelay ?? 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/** 当前连接状态 */
|
||||
get state(): ConnectionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/** 设备ID */
|
||||
get deviceId(): string {
|
||||
return this.options.deviceId;
|
||||
}
|
||||
|
||||
/** 设备类型 */
|
||||
get deviceType(): DeviceType {
|
||||
return this.options.deviceType;
|
||||
}
|
||||
|
||||
/** Socket ID(连接后可用) */
|
||||
get socketId(): string | undefined {
|
||||
return this.socket?.id;
|
||||
}
|
||||
|
||||
/** 是否已连接 */
|
||||
get isConnected(): boolean {
|
||||
return this._state === "connected" || this._state === "registered";
|
||||
}
|
||||
|
||||
/** 是否已注册 */
|
||||
get isRegistered(): boolean {
|
||||
return this._state === "registered";
|
||||
}
|
||||
|
||||
/** 连接到服务器 */
|
||||
connect(): this {
|
||||
if (this.socket) {
|
||||
return this;
|
||||
}
|
||||
|
||||
this.setState("connecting");
|
||||
|
||||
this.socket = io(this.options.url, {
|
||||
path: this.options.path,
|
||||
reconnection: this.options.autoReconnect,
|
||||
reconnectionDelay: this.options.reconnectDelay,
|
||||
});
|
||||
|
||||
this.setupListeners();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 断开连接 */
|
||||
disconnect(): this {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
this.setState("disconnected");
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 发送消息给指定设备 */
|
||||
send<T = unknown>(
|
||||
to: string,
|
||||
action: string,
|
||||
payload: T,
|
||||
messageId?: string
|
||||
): string {
|
||||
if (!this.socket || !this.isRegistered) {
|
||||
throw new Error("Not registered");
|
||||
}
|
||||
|
||||
const id = messageId ?? this.generateMessageId();
|
||||
const message: RoutedMessage<T> = {
|
||||
id,
|
||||
uid: null,
|
||||
from: this.options.deviceId,
|
||||
to,
|
||||
action,
|
||||
payload,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.socket.emit(GatewayEvents.SEND, message);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 发送 ping */
|
||||
ping(data: PingPayload = {}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket || !this.isConnected) {
|
||||
reject(new Error("Not connected"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit(
|
||||
GatewayEvents.PING,
|
||||
data,
|
||||
(response: { event: string; data: string }) => {
|
||||
resolve(response.data);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册连接回调 */
|
||||
onConnect(callback: (socketId: string) => void): this {
|
||||
this.callbacks.onConnect = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册断开回调 */
|
||||
onDisconnect(callback: (reason: string) => void): this {
|
||||
this.callbacks.onDisconnect = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册成功回调 */
|
||||
onRegistered(callback: (deviceId: string) => void): this {
|
||||
this.callbacks.onRegistered = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册消息回调 */
|
||||
onMessage(callback: (message: RoutedMessage) => void): this {
|
||||
this.callbacks.onMessage = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册发送失败回调 */
|
||||
onSendError(callback: (error: SendErrorResponse) => void): this {
|
||||
this.callbacks.onSendError = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册 pong 回调 */
|
||||
onPong(callback: (data: string) => void): this {
|
||||
this.callbacks.onPong = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册错误回调 */
|
||||
onError(callback: (error: Error) => void): this {
|
||||
this.callbacks.onError = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册状态变化回调 */
|
||||
onStateChange(callback: (state: ConnectionState) => void): this {
|
||||
this.callbacks.onStateChange = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState): void {
|
||||
if (this._state !== state) {
|
||||
this._state = state;
|
||||
this.callbacks.onStateChange?.(state);
|
||||
}
|
||||
}
|
||||
|
||||
private generateMessageId(): string {
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
private register(): void {
|
||||
if (!this.socket) return;
|
||||
|
||||
this.socket.emit(GatewayEvents.REGISTER, {
|
||||
deviceId: this.options.deviceId,
|
||||
deviceType: this.options.deviceType,
|
||||
metadata: this.options.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
private setupListeners(): void {
|
||||
if (!this.socket) return;
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
this.setState("connected");
|
||||
this.callbacks.onConnect?.(this.socket!.id!);
|
||||
// 连接后自动注册
|
||||
this.register();
|
||||
});
|
||||
|
||||
this.socket.on("disconnect", (reason: string) => {
|
||||
this.setState("disconnected");
|
||||
this.callbacks.onDisconnect?.(reason);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
GatewayEvents.REGISTERED,
|
||||
(response: RegisteredResponse) => {
|
||||
if (response.success) {
|
||||
this.setState("registered");
|
||||
this.callbacks.onRegistered?.(response.deviceId);
|
||||
} else {
|
||||
this.callbacks.onError?.(new Error(response.error ?? "Registration failed"));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(GatewayEvents.RECEIVE, (message: RoutedMessage) => {
|
||||
this.callbacks.onMessage?.(message);
|
||||
});
|
||||
|
||||
this.socket.on(GatewayEvents.SEND_ERROR, (error: SendErrorResponse) => {
|
||||
this.callbacks.onSendError?.(error);
|
||||
});
|
||||
|
||||
this.socket.on(GatewayEvents.PONG, (data: string) => {
|
||||
this.callbacks.onPong?.(data);
|
||||
});
|
||||
|
||||
this.socket.on("connect_error", (error: Error) => {
|
||||
this.callbacks.onError?.(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
18
src/shared/gateway-sdk/index.ts
Normal file
18
src/shared/gateway-sdk/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export { GatewayClient } from "./client.js";
|
||||
export {
|
||||
GatewayEvents,
|
||||
type DeviceType,
|
||||
type DeviceInfo,
|
||||
type RegisterPayload,
|
||||
type RegisteredResponse,
|
||||
type RoutedMessage,
|
||||
type SendErrorResponse,
|
||||
type GatewayClientOptions,
|
||||
type GatewayClientCallbacks,
|
||||
type ConnectionState,
|
||||
type PingPayload,
|
||||
type PongResponse,
|
||||
} from "./types.js";
|
||||
|
||||
// Actions
|
||||
export * from "./actions/index.js";
|
||||
132
src/shared/gateway-sdk/types.ts
Normal file
132
src/shared/gateway-sdk/types.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/** WebSocket 事件名称 */
|
||||
export const GatewayEvents = {
|
||||
// 系统事件
|
||||
PING: "ping",
|
||||
PONG: "pong",
|
||||
REGISTER: "register",
|
||||
REGISTERED: "registered",
|
||||
|
||||
// 消息路由
|
||||
SEND: "send",
|
||||
RECEIVE: "receive",
|
||||
SEND_ERROR: "send_error",
|
||||
} as const;
|
||||
|
||||
// ============ 设备相关 ============
|
||||
|
||||
/** 设备类型 */
|
||||
export type DeviceType = "client" | "agent";
|
||||
|
||||
/** 设备信息 */
|
||||
export interface DeviceInfo {
|
||||
deviceId: string;
|
||||
deviceType: DeviceType;
|
||||
metadata?: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
/** 注册请求 */
|
||||
export interface RegisterPayload {
|
||||
deviceId: string;
|
||||
deviceType: DeviceType;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 注册响应 */
|
||||
export interface RegisteredResponse {
|
||||
success: boolean;
|
||||
deviceId: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ============ 消息路由 ============
|
||||
|
||||
/** 路由消息 */
|
||||
export interface RoutedMessage<T = unknown> {
|
||||
/** 消息唯一ID */
|
||||
id: string;
|
||||
/** 用户ID(登录后填充) */
|
||||
uid: string | null;
|
||||
/** 发送者 deviceId */
|
||||
from: string;
|
||||
/** 接收者 deviceId */
|
||||
to: string;
|
||||
/** 动作类型 */
|
||||
action: string;
|
||||
/** 消息内容 */
|
||||
payload: T;
|
||||
/** 时间戳 */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/** 发送失败响应 */
|
||||
export interface SendErrorResponse {
|
||||
messageId: string;
|
||||
error: string;
|
||||
code: "DEVICE_NOT_FOUND" | "NOT_REGISTERED" | "INVALID_MESSAGE";
|
||||
}
|
||||
|
||||
// ============ Ping/Pong ============
|
||||
|
||||
/** Ping 请求 */
|
||||
export interface PingPayload {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Ping 响应 */
|
||||
export interface PongResponse {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
// ============ 客户端配置 ============
|
||||
|
||||
/** 连接配置 */
|
||||
export interface GatewayClientOptions {
|
||||
/** 服务器地址,如 http://localhost:3000 */
|
||||
url: string;
|
||||
/** WebSocket 路径,默认 /ws */
|
||||
path?: string | undefined;
|
||||
/** 设备ID */
|
||||
deviceId: string;
|
||||
/** 设备类型 */
|
||||
deviceType: DeviceType;
|
||||
/** 设备元数据 */
|
||||
metadata?: Record<string, unknown> | undefined;
|
||||
/** 自动重连,默认 true */
|
||||
autoReconnect?: boolean | undefined;
|
||||
/** 重连延迟(毫秒),默认 1000 */
|
||||
reconnectDelay?: number | undefined;
|
||||
}
|
||||
|
||||
/** 连接状态 */
|
||||
export type ConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "registered";
|
||||
|
||||
/** 事件回调类型 */
|
||||
export interface GatewayClientCallbacks {
|
||||
onConnect?: (socketId: string) => void;
|
||||
onDisconnect?: (reason: string) => void;
|
||||
onRegistered?: (deviceId: string) => void;
|
||||
onMessage?: (message: RoutedMessage) => void;
|
||||
onSendError?: (error: SendErrorResponse) => void;
|
||||
onPong?: (data: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onStateChange?: (state: ConnectionState) => void;
|
||||
}
|
||||
|
||||
// ============ 兼容旧API(可删除) ============
|
||||
|
||||
/** @deprecated 使用 RoutedMessage */
|
||||
export interface SendMessagePayload {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** @deprecated 使用 RoutedMessage */
|
||||
export interface BroadcastMessage {
|
||||
from: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from "./types.js";
|
||||
export * from "./gateway-sdk/index.js";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue