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:
yushen 2026-01-28 16:46:51 +08:00
parent b36769f913
commit 7d94b40a11
19 changed files with 2237 additions and 5 deletions

View 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;
}

View 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";

View 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;
}

View file

@ -0,0 +1,11 @@
/** Stream Action - 流式消息传输 */
export const StreamAction = "stream" as const;
/** 流消息 payload */
export interface StreamPayload<T = unknown> {
/** 流 ID用于关联同一个流的所有消息 */
streamId: string;
/** 数据 */
data: T;
}

View 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);
});
}
}

View 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";

View 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;
}

View file

@ -1 +1,2 @@
export * from "./types.js";
export * from "./gateway-sdk/index.js";