feat: Implement LiveCode module with WebSocket and SSE support

- Added config management using `useConfig` for environment variables.
- Created `LiveCode` class to manage WebSocket connections and routing.
- Implemented `SSEManager` for Server-Sent Events handling.
- Developed `WSSManager` for managing WebSocket connections with heartbeat functionality.
- Introduced `ReconnectingWebSocket` class for robust WebSocket client with automatic reconnection.
- Added test files for live application demonstrating WebSocket and TCP server integration.
This commit is contained in:
2026-02-02 23:29:58 +08:00
parent 5774391bbe
commit a76c2235ea
19 changed files with 871 additions and 385 deletions

View File

@@ -7,7 +7,8 @@ import { AssistantInit, parseHomeArg } from '@/services/init/index.ts';
import { configDir as HomeConfigDir } from '@/module/assistant/config/index.ts';
import { useContextKey } from '@kevisual/use-config/context';
import { AssistantQuery } from '@/module/assistant/query/index.ts';
import { config } from '@/module/config.ts';
export { config };
const manualParse = parseHomeArg(HomeConfigDir);
const _configDir = manualParse.configDir;
export const configDir = AssistantInit.detectConfigDir(_configDir);
@@ -29,7 +30,7 @@ type Runtime = {
isServer?: boolean;
}
export const runtime: Runtime = useContextKey('runtime', () => {
console.log('Runtime detected:', manualParse);
console.log('Runtime detected:', manualParse.isDev);
return {
type: 'client',
isServer: manualParse.isServer,

View File

@@ -107,11 +107,13 @@ export type AssistantConfigData = {
* 例子: { proxy: [ { type: 'router', api: 'https://localhost:50002/api/router' } ] }
* base: 是否使用 /api/router的基础路径默认false
* lightcode: 是否启用lightcode路由默认false
* livecode: 是否启用livecode路由实时的注册和销毁默认false
*/
router?: {
proxy: ProxyInfo[];
base?: boolean;
lightcode?: boolean;
livecode?: boolean;
}
routes?: AssistantRoutes[],
/**

View File

@@ -36,7 +36,23 @@ export class ModuleResolver {
// 相对路径 ./xxx 或 ../xxx
const localFullPath = path.resolve(this.root, routePath);
return this.fileIsExists(localFullPath) ? localFullPath : routePath;
if (!this.fileIsExists(localFullPath)) {
return routePath;
}
// 如果是目录,解析入口文件
if (fs.statSync(localFullPath).isDirectory()) {
const pkgJsonPath = path.join(localFullPath, 'package.json');
const pkg = this.readPackageJson(pkgJsonPath);
if (pkg) {
const entryPath = this.resolvePackageExport(pkg, '');
return path.join(localFullPath, entryPath);
}
// 没有 package.json默认使用 index.ts
return path.join(localFullPath, 'index.ts');
}
return localFullPath;
}
/** 解析 scoped 包 */

View File

@@ -222,7 +222,7 @@ export class AssistantApp extends Manager {
const routeStr = typeof route === 'string' ? route : route.path;
const resolvedPath = this.resolver.resolve(routeStr);
await import(resolvedPath);
console.log('路由已初始化', route);
console.log('[routes] 路由已初始化', route, resolvedPath);
} catch (err) {
console.error('初始化路由失败', route, err);
}

View File

@@ -0,0 +1,10 @@
import { useConfig } from '@kevisual/use-config';
import { HomeConfigDir } from './assistant/config/index.ts';
import path from 'node:path';
export const config = useConfig({
dotenvOpts: {
path: [path.join(HomeConfigDir, '.env'), '.env'],
}
})
// console.log('配置文件目录:', config, HomeConfigDir);

View File

@@ -165,7 +165,11 @@ export const initLightCode = async (opts: opts) => {
} else {
ctx.throw(runRes2.error || 'Lightcode 路由执行失败');
}
}).addTo(app);
}).addTo(app, {
override: false,
// @ts-ignore
overwrite: false
});// 不允许覆盖已存在的路由
}
}

View File

@@ -0,0 +1,131 @@
import { WSSManager } from './wss.ts';
import { App, Route } from '@kevisual/router'
import { WebSocketReq } from '@kevisual/router'
import { EventEmitter } from 'eventemitter3';
import { customAlphabet } from 'nanoid';
const letter = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const customId = customAlphabet(letter, 16);
export class LiveCode {
wssManager: WSSManager;
app: App;
emitter: EventEmitter;
constructor(app: App) {
this.wssManager = new WSSManager({ heartbeatInterval: 5000 });
this.app = app;
this.emitter = new EventEmitter();
console.log('[LiveCode] 模块已初始化');
}
async conn(req: WebSocketReq) {
const { ws, emitter, id } = req;
const that = this;
// @ts-ignore
let wid = ws.data?.wid;
if (!wid) {
const _id = this.wssManager.addConnection(req, { userId: id });
// @ts-ignore
ws.data.wid = _id;
emitter.once('close--' + id, () => {
that.wssManager.closeConnection(_id);
this.deinitAppRoutes(_id);
});
console.log('[LiveCode]新的 WebSocket 连接已打开', _id);
const res = await that.init(_id);
if (res.code === 200) {
console.log('[LiveCode]初始化路由列表完成');
that.initAppRoutes(res.data?.list || [], _id);
} else {
console.error('[LiveCode]初始化路由列表失败:', res?.message);
}
return this;
}
that.onMessage(req);
return this;
}
getWss(id: string) {
return this.wssManager.getConnection(id)
}
async init(id: string): Promise<{ code: number, message?: string, data?: any }> {
return this.sendData({ path: 'router', key: 'list', }, id);
}
sendData(data: any, id: string): Promise<{ code: number, message?: string, data?: any }> {
const reqId = customId()
const wss = this.getWss(id);
if (!wss) {
return Promise.resolve({ code: 500, message: '连接不存在或已关闭' });
}
const emitter = this.emitter;
const wsReq = wss.wsReq;
try {
wsReq.ws.send(JSON.stringify({
type: 'router',
id: reqId,
data: data
}));
} catch (error) {
console.error('[LiveCode]发送数据失败:', error);
return Promise.resolve({ code: 500, message: '发送数据失败' });
}
return new Promise((resolve) => {
const timeout = setTimeout(() => {
resolve({ code: 500, message: '请求超时' });
emitter.off(reqId, listenOnce);
}, 5000);
const listenOnce = (resData: any) => {
clearTimeout(timeout);
resolve(resData);
emitter.off(reqId, listenOnce);
}
emitter.once(reqId, listenOnce);
});
}
onMessage(req: WebSocketReq) {
const { data } = req;
if (data?.id) {
// console.log('LiveCode 收到消息:', data);
this.emitter.emit(data.id, data.data);
} else {
console.warn('[LiveCode] 未知的消息格式', data);
}
}
initAppRoutes(list: Route[], wid: string) {
for (const route of list) {
const path = route.path || '';
const id = route.id || '';
if (path.startsWith('router') || path.startsWith('auth') || path.startsWith('admin-autu') || path.startsWith('call')) {
continue;
}
// console.log('注册路由:', route.path, route.description, route.metadata, route.id);
this.app.route({
path: route.id,
key: route.key,
description: route.description,
metadata: {
...route.metadata,
liveCodeId: wid
},
middleware: ['auth'],
}).define(async (ctx) => {
const { token, cookie, ...rest } = ctx.query;
const tokenUser = ctx.state.tokernUser;
const res = await this.sendData({
id: route.id,
tokenUser,
payload: rest,
}, wid);
// console.log('路由响应数据:', res);
ctx.forward(res)
}).addTo(this.app, {
// override: false,
// // @ts-ignore
// overwrite: false
});
}
}
deinitAppRoutes(wid: string) {
const routesToRemove = this.app.routes.filter(route => route.metadata?.liveCodeId === wid);
for (const route of routesToRemove) {
this.app.removeById(route.id);
}
}
}

View File

@@ -0,0 +1,134 @@
import { nanoid } from "nanoid";
type ConnectionInfo = {
id: string;
writer: WritableStreamDefaultWriter;
stream: ReadableStream<any>;
connectedAt: Date;
heartbeatInterval: NodeJS.Timeout | null;
userId?: string;
};
export class SSEManager {
private connections: Map<string, ConnectionInfo> = new Map();
private userConnections: Map<string, Set<string>> = new Map(); // userId -> connectionIds
constructor() {
// 初始化逻辑
}
createConnection(info?: { userId?: string }): ConnectionInfo {
const connectionId = nanoid(16);
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
// 存储连接信息
const connectionInfo = {
id: connectionId,
writer,
stream: readable,
connectedAt: new Date(),
heartbeatInterval: null,
userId: info?.userId
};
this.connections.set(connectionId, connectionInfo);
// 添加到用户索引
if (info?.userId) {
const userSet = this.userConnections.get(info.userId) || new Set();
userSet.add(connectionId);
this.userConnections.set(info.userId, userSet);
}
return connectionInfo;
}
sendToConnection(connectionId: string, data: any) {
const connection = this.connections.get(connectionId);
if (connection) {
const message = `data: ${JSON.stringify(data)}\n\n`;
return connection.writer.write(new TextEncoder().encode(message));
}
throw new Error(`Connection ${connectionId} not found`);
}
getConnection(connectionId: string) {
return this.connections.get(connectionId);
}
broadcast(data: any, opts?: { userId?: string }) {
const message = `data: ${JSON.stringify(data)}\n\n`;
const promises = [];
// 指定 userId只发送给目标用户通过索引快速查找
if (opts?.userId) {
const userConnIds = this.userConnections.get(opts.userId);
if (userConnIds) {
for (const connId of userConnIds) {
const conn = this.connections.get(connId);
if (conn) {
promises.push(
conn.writer.write(new TextEncoder().encode(message))
.catch(() => {
this.closeConnection(connId);
})
);
}
}
}
return Promise.all(promises);
}
// 未指定 userId广播给所有人
for (const [id, connection] of this.connections) {
promises.push(
connection.writer.write(new TextEncoder().encode(message))
.catch(() => {
this.closeConnection(id);
})
);
}
return Promise.all(promises);
}
closeConnection(connectionId: string) {
const connection = this.connections.get(connectionId);
if (connection) {
// 清理心跳定时器
if (connection.heartbeatInterval) {
clearInterval(connection.heartbeatInterval);
}
// 从用户索引中移除
if (connection.userId) {
const userSet = this.userConnections.get(connection.userId);
if (userSet) {
userSet.delete(connectionId);
if (userSet.size === 0) {
this.userConnections.delete(connection.userId);
}
}
}
// 关闭写入器
connection.writer.close().catch(console.error);
// 从管理器中移除
this.connections.delete(connectionId);
console.log(`Connection ${connectionId} closed`);
return true;
}
return false;
}
closeAllConnections() {
for (const [connectionId, connection] of this.connections) {
this.closeConnection(connectionId);
}
}
getActiveConnections() {
return Array.from(this.connections.keys());
}
}

View File

@@ -0,0 +1,213 @@
import { nanoid } from "nanoid";
import { WebSocketReq } from '@kevisual/router'
type ConnectionInfo = {
id: string;
wsReq: WebSocketReq;
connectedAt: Date;
heartbeatInterval: NodeJS.Timeout | null;
userId?: string;
lastHeartbeat: Date;
};
export class WSSManager {
private connections: Map<string, ConnectionInfo> = new Map();
private userConnections: Map<string, Set<string>> = new Map();
private heartbeatInterval: number = 30000; // 默认30秒
constructor(opts?: { heartbeatInterval?: number }) {
if (opts?.heartbeatInterval) {
this.heartbeatInterval = opts.heartbeatInterval;
}
}
/**
* 添加 WebSocket 连接
*/
addConnection(wsReq: WebSocketReq, info?: { userId?: string }): string {
const connectionId = nanoid(16);
const now = new Date();
const connectionInfo: ConnectionInfo = {
id: connectionId,
wsReq: wsReq,
connectedAt: now,
heartbeatInterval: null,
userId: info?.userId,
lastHeartbeat: now,
};
// 启动心跳
this.startHeartbeat(connectionInfo);
// 存储连接
this.connections.set(connectionId, connectionInfo);
// 添加到用户索引
if (info?.userId) {
const userSet = this.userConnections.get(info.userId) || new Set();
userSet.add(connectionId);
this.userConnections.set(info.userId, userSet);
}
return connectionId;
}
/**
* 启动心跳
*/
private startHeartbeat(connection: ConnectionInfo) {
connection.heartbeatInterval = setInterval(() => {
const ws = connection.wsReq.ws;
ws.send(JSON.stringify({ type: 'heartbeat', timestamp: new Date().toISOString() }));
connection.lastHeartbeat = new Date();
console.log(`[LiveCode] 发送心跳给连接 ${connection.id}`);
}, this.heartbeatInterval);
}
/**
* 发送消息到指定连接
*/
sendToConnection(connectionId: string, data: any): boolean {
const connection = this.connections.get(connectionId);
if (connection) {
// 发送消息
connection.wsReq.ws.send(JSON.stringify(data));
return true;
}
return false;
}
/**
* 发送消息到指定用户的所有连接
*/
sendToUser(userId: string, data: any): number {
const userConnIds = this.userConnections.get(userId);
if (!userConnIds) return 0;
let sentCount = 0;
for (const connId of userConnIds) {
if (this.sendToConnection(connId, data)) {
sentCount++;
}
}
return sentCount;
}
/**
* 广播消息到所有连接
*/
broadcast(data: any, opts?: { userId?: string; excludeConnectionId?: string }): number {
if (opts?.userId) {
// 发送给指定用户
return this.sendToUser(opts.userId, data);
}
let sentCount = 0;
for (const [connId, connection] of this.connections) {
// 跳过排除的连接
if (opts?.excludeConnectionId && connId === opts.excludeConnectionId) {
continue;
}
if (this.sendToConnection(connId, data)) {
sentCount++;
}
}
return sentCount;
}
/**
* 获取连接信息
*/
getConnection(connectionId: string): ConnectionInfo | undefined {
return this.connections.get(connectionId);
}
/**
* 获取用户的所有连接
*/
getUserConnections(userId: string): ConnectionInfo[] {
const userConnIds = this.userConnections.get(userId);
if (!userConnIds) return [];
return Array.from(userConnIds)
.map((id) => this.connections.get(id))
.filter((conn): conn is ConnectionInfo => conn !== undefined);
}
/**
* 检查连接是否活跃(基于心跳)
*/
isConnectionAlive(connectionId: string, timeout: number = 60000): boolean {
const connection = this.connections.get(connectionId);
if (!connection) return false;
const now = new Date();
const timeSinceLastHeartbeat = now.getTime() - connection.lastHeartbeat.getTime();
return timeSinceLastHeartbeat < timeout;
}
/**
* 关闭指定连接
*/
closeConnection(connectionId: string): boolean {
const connection = this.connections.get(connectionId);
if (connection) {
// 清理心跳定时器
if (connection.heartbeatInterval) {
clearInterval(connection.heartbeatInterval);
}
// 从用户索引中移除
if (connection.userId) {
const userSet = this.userConnections.get(connection.userId);
if (userSet) {
userSet.delete(connectionId);
if (userSet.size === 0) {
this.userConnections.delete(connection.userId);
}
}
}
try {
connection.wsReq.ws.close();
} catch (error) {
console.error(`Error closing WebSocket for connection ${connectionId}:`, error);
}
// 从管理器中移除
this.connections.delete(connectionId);
console.log(`WebSocket connection ${connectionId} closed`);
return true;
}
return false;
}
/**
* 关闭所有连接
*/
closeAllConnections(): void {
for (const [connectionId] of this.connections) {
this.closeConnection(connectionId);
}
}
/**
* 获取活跃连接列表
*/
getActiveConnections(): string[] {
return Array.from(this.connections.keys());
}
/**
* 获取连接数量
*/
getConnectionCount(): number {
return this.connections.size;
}
/**
* 获取用户连接数量
*/
getUserConnectionCount(userId: string): number {
return this.userConnections.get(userId)?.size || 0;
}
}

View File

@@ -32,7 +32,7 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
return fileProxy(req, res, {
path: localProxyProxy.path,
rootPath: localProxy.pagesDir,
indexPath: localProxyProxy.indexPath,
indexPath: localProxyProxy.file?.indexPath,
});
}
res.statusCode = 404;

View File

@@ -1,20 +0,0 @@
import { app } from '@/app.ts';
// import { Hotkeys } from '@kevisual/hot-api';
import { Hotkeys } from './lib.ts';
import { useContextKey } from '@kevisual/context';
app.route({
path: 'key-sender',
// middleware: ['admin-auth']
}).define(async (ctx) => {
let keys = ctx.query.keys;
if (keys.includes(' ')) {
keys = keys.replace(/\s+/g, '+');
}
const hotKeys: Hotkeys = useContextKey('hotkeys', () => new Hotkeys());
if (typeof keys === 'string') {
await hotKeys.pressHotkey({
hotkey: keys,
});
}
ctx.body = 'ok';
}).addTo(app);

View File

@@ -1,89 +0,0 @@
import { keyboard, Key } from "@nut-tree-fork/nut-js";
/**
* 控制功能部分的案件映射
*/
export const keyMap: Record<string, Key> = {
'ctrl': Key.LeftControl,
'leftctrl': Key.LeftControl,
'rightctrl': Key.RightControl,
'alt': Key.LeftAlt,
'leftalt': Key.LeftAlt,
'rightalt': Key.RightAlt,
'shift': Key.LeftShift,
'leftshift': Key.LeftShift,
'rightshift': Key.RightShift,
'meta': Key.LeftSuper,
'cmd': Key.LeftCmd,
'win': Key.LeftWin,
// 根据操作系统选择 Ctrl 或 Command 键
'ctrlorcommand': process.platform === 'darwin' ? Key.LeftCmd : Key.LeftControl,
};
/**
* 将快捷键字符串转换为 Key 枚举值
* @param hotkey
* @returns
*/
export const parseHotkey = (hotkey: string): Key[] => {
return hotkey
.toLowerCase()
.split('+')
.map(key => {
const trimmed = key.trim().toLowerCase();
// 如果是修饰键,从映射表中获取
if (keyMap[trimmed]) {
return keyMap[trimmed];
}
// 如果是字母,转换为大写并查找对应的 Key
if (trimmed.length === 1 && /[a-z]/.test(trimmed)) {
const upperKey = trimmed.toUpperCase();
return Key[upperKey as keyof typeof Key] as Key;
}
// 其他情况直接查找
return Key[trimmed as keyof typeof Key] as Key;
})
.filter((key): key is Key => key !== undefined);
}
type PressHostKeysOptions = {
hotkey: string;
durationMs?: number;
}
export const pressHotkey = async (opts: PressHostKeysOptions): Promise<boolean> => {
const { hotkey, durationMs = 100 } = opts;
const keys = parseHotkey(hotkey);
console.log('准备模拟按下快捷键:', hotkey);
// 同时按下所有键
await keyboard.pressKey(...keys);
// 短暂延迟后释放
await new Promise(resolve => setTimeout(resolve, durationMs));
// 释放所有键
await keyboard.releaseKey(...keys);
return true
}
/**
* 模拟按下一组快捷键,支持逗号分隔的多个快捷键
* @param opts
* @returns
*/
export const pressHotkeys = async (opts: PressHostKeysOptions): Promise<boolean> => {
let { hotkey } = opts;
hotkey = hotkey.replace(/\s+/g, ''); // 去除所有空格
const hotkeyList = hotkey.split(',').map(hk => hk.trim());
if (hotkeyList.length === 0) {
return await pressHotkey({ ...opts, hotkey });
}
for (const hk of hotkeyList) {
await pressHotkey({ ...opts, hotkey: hk });
// 每个快捷键之间稍作延迟
await new Promise(resolve => setTimeout(resolve, 200));
}
return true;
}
export class Hotkeys {
pressHotkey = pressHotkey;
pressHotkeys = pressHotkeys;
}

View File

@@ -5,8 +5,6 @@ import './ai/index.ts';
import './user/index.ts';
import './call/index.ts'
// TODO: 移除
// import './hot-api/key-sender/index.ts';
import './opencode/index.ts';
import './remote/index.ts';
import './kevisual/index.ts'

View File

@@ -1,6 +1,6 @@
import { useContextKey } from '@kevisual/context';
import { app, assistantConfig, runtime } from './app.ts';
import { proxyRoute, proxyWs } from './services/proxy/proxy-page-index.ts';
import { proxyLivecodeWs, proxyRoute, proxyWs } from './services/proxy/proxy-page-index.ts';
import './routes/index.ts';
import './routes-simple/index.ts';
@@ -49,6 +49,7 @@ export const runServer = async (port: number = 51515, listenPath = '127.0.0.1')
func: proxyRoute as any,
},
...proxyWs(),
...proxyLivecodeWs(),
qwenAsr,
]);
const manager = useContextKey('manager', new AssistantApp(assistantConfig, app));

View File

@@ -1,13 +1,14 @@
import { createApiProxy, ProxyInfo, proxy } from '@/module/assistant/index.ts';
import http from 'node:http';
import { LocalProxy } from './local-proxy.ts';
import { assistantConfig, simpleRouter } from '@/app.ts';
import { assistantConfig, simpleRouter, app } from '@/app.ts';
import { log, logger } from '@/module/logger.ts';
import { getToken } from '@/module/http-token.ts';
import { getTokenUserCache } from '@/routes/index.ts';
import type { WebSocketListenerFun } from "@kevisual/router";
import WebSocket from 'ws';
import { renderNoAuthAndLogin } from '@/module/assistant/html/login.ts';
import { LiveCode } from '@/module/livecode/index.ts';
const localProxy = new LocalProxy({});
localProxy.initFromAssistantConfig(assistantConfig);
@@ -234,6 +235,27 @@ export const proxyWs = () => {
}
return proxyApi.map(createProxyInfo);
};
const liveCode = new LiveCode(app)
export const proxyLivecodeWs = () => {
const livecode = assistantConfig.getCacheAssistantConfig()?.router?.livecode ?? true;
if (!livecode) {
return [];
}
const fun: WebSocketListenerFun = async (req, res) => {
const { ws, emitter, id, data } = req;
// if (!id) {
// ws.send(JSON.stringify({ type: 'error', message: 'not found id' }));
// ws.close();
// return;
// }
liveCode.conn(req)
}
return [{
path: '/livecode/ws',
io: true,
func: fun
}]
}
export const createProxyInfo = (proxyApiItem: ProxyInfo) => {
const func: WebSocketListenerFun = async (req, res) => {
const { ws, emitter, id, data } = req;

View File

@@ -0,0 +1,215 @@
import { App } from '@kevisual/router'
import { WebSocket } from 'ws'
import net from 'net';
type ReconnectConfig = {
maxRetries?: number; // 最大重试次数,默认无限
retryDelay?: number; // 重试延迟(ms)默认1000
maxDelay?: number; // 最大延迟(ms)默认30000
backoffMultiplier?: number; // 退避倍数默认2
};
class ReconnectingWebSocket {
private ws: WebSocket | null = null;
private url: string;
private config: Required<ReconnectConfig>;
private retryCount: number = 0;
private reconnectTimer: NodeJS.Timeout | null = null;
private isManualClose: boolean = false;
private messageHandlers: Array<(data: any) => void> = [];
private openHandlers: Array<() => void> = [];
private closeHandlers: Array<(code: number, reason: Buffer) => void> = [];
private errorHandlers: Array<(error: Error) => void> = [];
constructor(url: string, config: ReconnectConfig = {}) {
this.url = url;
this.config = {
maxRetries: config.maxRetries ?? Infinity,
retryDelay: config.retryDelay ?? 1000,
maxDelay: config.maxDelay ?? 30000,
backoffMultiplier: config.backoffMultiplier ?? 2,
};
}
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN) {
return;
}
console.log(`正在连接到 ${this.url}...`);
this.ws = new WebSocket(this.url);
this.ws.on('open', () => {
console.log('WebSocket 连接已打开');
this.retryCount = 0;
this.openHandlers.forEach(handler => handler());
this.send({ type: 'heartbeat', timestamp: new Date().toISOString() });
});
this.ws.on('message', (data: any) => {
this.messageHandlers.forEach(handler => {
try {
const message = JSON.parse(data.toString());
handler(message);
} catch {
handler(data.toString());
}
});
});
this.ws.on('close', (code: number, reason: Buffer) => {
console.log(`WebSocket 连接已关闭: code=${code}, reason=${reason.toString()}`);
this.closeHandlers.forEach(handler => handler(code, reason));
if (!this.isManualClose) {
this.scheduleReconnect();
}
});
this.ws.on('error', (error: Error) => {
console.error('WebSocket 错误:', error.message);
this.errorHandlers.forEach(handler => handler(error));
});
}
private scheduleReconnect(): void {
if (this.reconnectTimer) {
return;
}
if (this.retryCount >= this.config.maxRetries) {
console.error(`已达到最大重试次数 (${this.config.maxRetries}),停止重连`);
return;
}
// 计算延迟(指数退避)
const delay = Math.min(
this.config.retryDelay * Math.pow(this.config.backoffMultiplier, this.retryCount),
this.config.maxDelay
);
this.retryCount++;
console.log(`将在 ${delay}ms 后进行第 ${this.retryCount} 次重连尝试...`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
send(data: any): boolean {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
return true;
}
console.warn('WebSocket 未连接,无法发送消息');
return false;
}
onMessage(handler: (data: any) => void): void {
this.messageHandlers.push(handler);
}
onOpen(handler: () => void): void {
this.openHandlers.push(handler);
}
onClose(handler: (code: number, reason: Buffer) => void): void {
this.closeHandlers.push(handler);
}
onError(handler: (error: Error) => void): void {
this.errorHandlers.push(handler);
}
close(): void {
this.isManualClose = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
getReadyState(): number {
return this.ws?.readyState ?? WebSocket.CLOSED;
}
getRetryCount(): number {
return this.retryCount;
}
}
const app = new App();
app.route({
path: 'livecode-status',
description: 'LiveCode 状态路由',
metadata: {
tags: ['livecode', 'status'],
},
}).define(async (ctx) => {
ctx.body = {
status: 'LiveCode 模块运行正常',
timestamp: new Date().toISOString(),
};
}).addTo(app)
app.createRouteList();
await new Promise((resolve) => setTimeout(resolve, 1000));
// 创建支持断开重连的 WebSocket 客户端
const ws = new ReconnectingWebSocket('ws://localhost:51516/livecode/ws?id=test-live-app', {
maxRetries: Infinity, // 无限重试
retryDelay: 1000, // 初始重试延迟 1 秒
maxDelay: 30000, // 最大延迟 30 秒
backoffMultiplier: 2, // 指数退避倍数
});
ws.onMessage(async (message) => {
console.log('收到消息:', message);
if (message.type === 'router' && message.id) {
console.log('收到路由响应:', message);
const data = message?.data;
if (!data) {
ws.send({
type: 'router',
id: message.id,
data: { code: 500, message: 'No data received' }
});
return;
}
const res = await app.run(message.data);
console.log('路由处理结果:', res);
ws.send({
type: 'router',
id: message.id,
data: res
});
}
});
ws.onOpen(() => {
console.log('连接已建立,可以开始通信');
});
ws.onError((error) => {
console.error('连接错误:', error.message);
});
ws.onClose((code, reason) => {
console.log(`连接关闭: ${code} - ${reason.toString()}`);
});
// 启动连接
ws.connect();
net.createServer((socket) => {
console.log('TCP 客户端已连接');
}).listen(61616, () => {
console.log('TCP 服务器正在监听端口 61616');
});

View File

@@ -0,0 +1,75 @@
import { App } from '@kevisual/router'
import { WebSocket } from 'ws'
import { ReconnectingWebSocket, handleCallApp } from '@kevisual/router/ws'
import net from 'net';
const app = new App();
app.route({
path: 'livecode-status',
description: 'LiveCode 状态路由',
metadata: {
tags: ['livecode', 'status'],
},
}).define(async (ctx) => {
ctx.body = {
status: 'LiveCode 模块运行正常',
timestamp: new Date().toISOString(),
};
}).addTo(app)
app.createRouteList();
await new Promise((resolve) => setTimeout(resolve, 1000));
// 创建支持断开重连的 WebSocket 客户端
const ws = new ReconnectingWebSocket('ws://localhost:51516/livecode/ws?id=test-live-app', {
maxRetries: Infinity, // 无限重试
retryDelay: 1000, // 初始重试延迟 1 秒
maxDelay: 30000, // 最大延迟 30 秒
backoffMultiplier: 2, // 指数退避倍数
});
ws.onMessage(async (message) => {
console.log('收到消息:', message);
if (message.type === 'router' && message.id) {
console.log('收到路由响应:', message);
const data = message?.data;
if (!data) {
ws.send({
type: 'router',
id: message.id,
data: { code: 500, message: 'No data received' }
});
return;
}
const res = await app.run(message.data);
console.log('路由处理结果:', res);
ws.send({
type: 'router',
id: message.id,
data: res
});
}
});
ws.onOpen(() => {
console.log('连接已建立,可以开始通信');
});
ws.onError((error) => {
console.error('连接错误:', error.message);
});
ws.onClose((code, reason) => {
console.log(`连接关闭: ${code} - ${reason.toString()}`);
});
// 启动连接
ws.connect();
net.createServer((socket) => {
console.log('TCP 客户端已连接');
}).listen(61616, () => {
console.log('TCP 服务器正在监听端口 61616');
});