diff --git a/assistant/src/module/assistant/local-app-manager/assistant-app.ts b/assistant/src/module/assistant/local-app-manager/assistant-app.ts index c78822f..23270b8 100644 --- a/assistant/src/module/assistant/local-app-manager/assistant-app.ts +++ b/assistant/src/module/assistant/local-app-manager/assistant-app.ts @@ -17,7 +17,6 @@ export class AssistantApp extends Manager { config: AssistantConfig; pagesPath: string; remoteIsConnected = false; - attemptedConnectTimes = 0; remoteApp: RemoteApp | null = null; remoteUrl: string | null = null; private resolver: ModuleResolver; @@ -87,7 +86,7 @@ export class AssistantApp extends Manager { const enabled = opts?.enabled ?? share?.enabled ?? false; if (share && enabled !== false) { if (this.remoteApp) { - this.remoteApp.ws?.close(); + this.remoteApp.disconnect(); this.remoteApp = null; this.remoteIsConnected = false; } @@ -100,23 +99,28 @@ export class AssistantApp extends Manager { token, id, app: this.mainApp, + // 使用 RemoteApp 内置的自动重连机制 + autoReconnect: true, + reconnectDelay: 5000, // 首次重连延迟 5 秒 + maxReconnectAttempts: Infinity, // 无限重连 + enableBackoff: true, // 启用指数退避 }); const isConnect = await remoteApp.isConnect(); if (isConnect) { remoteApp.listenProxy(); this.remoteIsConnected = true; - // 清理已有的 close 事件监听器,防止多重绑定 - remoteApp.emitter.removeAllListeners('close'); - remoteApp.emitter.on('close', () => { - setTimeout(() => { - if (remoteApp.isError) { - console.error('远程应用发生错误,不重连'); - } else { - this.reconnectRemoteApp(); - } - }, 5 * 1000); // 第一次断开5秒后重连 + // 监听连接成功和关闭事件 + remoteApp.on('open', () => { + this.remoteIsConnected = true; + logger.debug('链接到了远程应用服务器'); + }); + remoteApp.on('close', () => { + this.remoteIsConnected = false; + console.log('远程连接已关闭,自动重连机制正在处理...'); + }); + remoteApp.on('maxReconnectAttemptsReached', () => { + logger.error('远程应用重连达到最大次数,停止重连'); }); - logger.debug('链接到了远程应用服务器'); const appId = id; const username = config?.auth.username || 'unknown'; const url = new URL(`/${username}/v1/${appId}`, config?.registry || 'https://kevisual.cn/'); @@ -208,29 +212,6 @@ export class AssistantApp extends Manager { } } } - async reconnectRemoteApp() { - console.log('重新连接到远程应用服务器...', this.attemptedConnectTimes); - const remoteApp = this.remoteApp;; - if (remoteApp) { - // 先关闭旧的 WebSocket,防止竞态条件 - if (remoteApp.ws) { - remoteApp.ws.close(); - } - remoteApp.init(); - this.attemptedConnectTimes += 1; - const isConnect = await remoteApp.isConnect(); - if (isConnect) { - remoteApp.listenProxy(); - this.attemptedConnectTimes = 0; - console.log('重新连接到了远程应用服务器'); - } else { - this.reconnectRemoteApp(); - setTimeout(() => { - this.initRouterApp() - }, 30 * 1000 + this.attemptedConnectTimes * 10 * 1000); // 30秒后重连 + 每次增加10秒 - } - } - } async initRoutes() { const routes = this.config.getConfig().routes || []; for (const route of routes) { diff --git a/assistant/src/module/remote-app/README.md b/assistant/src/module/remote-app/README.md new file mode 100644 index 0000000..94ba528 --- /dev/null +++ b/assistant/src/module/remote-app/README.md @@ -0,0 +1,272 @@ +# RemoteApp + +RemoteApp 是一个 WebSocket 远程应用连接类,支持断开自动重连机制。 + +## 功能特性 + +- WebSocket 远程代理连接 +- **断开自动重连** +- 指数退避算法 +- 可配置重连策略参数 +- 手动重连/断开控制 + +## 安装 + +```bash +npm install @kevisual/remote-app +``` + +## 基本使用 + +```typescript +import { RemoteApp } from '@kevisual/remote-app'; + +const app = new RemoteApp({ + url: 'https://kevisual.cn/ws/proxy', + id: 'my-app', + token: 'your-token', + app: mainApp +}); + +// 等待连接建立 +await app.isConnect(); + +// 监听代理消息 +app.listenProxy(); +``` + +## 断开重连配置 + +### 配置选项 + +| 选项 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `app` | `App` | - | 本地应用实例 | +| `url` | `string` | - | 远程服务地址 | +| `id` | `string` | - | 应用 ID | +| `token` | `string` | - | 认证令牌 | +| `emitter` | `EventEmitter` | `new EventEmitter()` | 事件发射器 | +| `autoReconnect` | `boolean` | `true` | 是否启用自动重连 | +| `maxReconnectAttempts` | `number` | `Infinity` | 最大重连次数 | +| `reconnectDelay` | `number` | `1000` | 初始重连延迟(毫秒) | +| `maxReconnectDelay` | `number` | `30000` | 重连延迟最大值(毫秒) | +| `enableBackoff` | `boolean` | `true` | 是否启用指数退避 | + +### 配置示例 + +```typescript +// 禁用自动重连 +const app = new RemoteApp({ + url: 'https://kevisual.cn/ws/proxy', + id: 'app1', + token: 'your-token', + autoReconnect: false +}); + +// 限制最大重连次数为 10 次 +const app = new RemoteApp({ + url: 'https://kevisual.cn/ws/proxy', + id: 'app1', + token: 'your-token', + maxReconnectAttempts: 10 +}); + +// 自定义重连延迟策略(线性增长) +const app = new RemoteApp({ + url: 'https://kevisual.cn/ws/proxy', + id: 'app1', + token: 'your-token', + enableBackoff: false, // 禁用指数退避 + reconnectDelay: 2000, // 固定 2 秒延迟 + maxReconnectAttempts5 // 最多重连 5 次 +}); + +// 快速重连策略(适合内网环境) +const app = new RemoteApp({ + url: 'ws://internal-server/ws', + id: 'app1', + reconnectDelay: 500, // 0.5 秒开始 + maxReconnectDelay: 5000, // 最大 5 秒 + enableBackoff: true +}); +``` + +## 方法 + +### `isConnect(): Promise` + +检查或等待连接建立。 + +```typescript +const connected = await app.isConnect(); +console.log(connected ? '已连接' : '连接失败'); +``` + +### `disconnect()` + +手动关闭连接并停止自动重连。 + +```typescript +app.disconnect(); +``` + +### `reconnect()` + +手动触发重连。 + +```typescript +app.reconnect(); +``` + +### `json(data: any)` + +发送 JSON 数据到远程服务。 + +```typescript +app.json({ type: 'ping' }); +``` + +### `listenProxy()` + +启动代理监听,处理远程请求。 + +```typescript +app.listenProxy(); +```` + +## 事件 + +### 可监听事件 + +| 事件 | 参数 | 说明 | +|------|------|------| +| `open` | `id: string` | 连接建立时触发 | +| `close` | `id: string` | 连接关闭时时触发 | +| `message` | `data: any` | 收到消息时触发 | +| `error` | `error: any` | 发生错误时触发 | +| `maxReconnectAttemptsReached` | `id: string` | 达到最大重连次数时触发 | +| `reconnectFailed` | `{ id, attempt, error }` | 单次重连失败时触发 | + +### 事件监听示例 + +```typescript +// 连接建立 +app.on('open', (id) => { + console.log(`应用 ${id} 已连接`); +}); + +// 连接关闭 +app.on('close', (id) => { + console.log(`应用 ${id} 连接已关闭`); +}); + +// 收到消息 +app.on('message', (data) => { + console.log('收到消息:', data); +}); + +// 发生错误 +app.on('error', (error) => { + console.error('连接错误:', error); +}); + +// 达到最大重连次数 +app.on('maxReconnectAttemptsReached', (id) => { + console.error(`应用 ${id} 已达到最大重连次数,停止重连`); + // 可以在这里提示用户或采取其他措施 +}); + +// 重连失败 +app.on('reconnectFailed', ({ id, attempt, error }) => { + console.error(`应用 ${id} 第 ${attempt} 次重连失败:`, error); +}); +``` + +## 重连机制说明 + +### 重连流程 + +1. 连接断开时触发 `close` 事件 +2. 检查是否满足自动重连条件(启用自动重连 + 非手动关闭) +3. 计算重连延迟(使用指数退避算法) +4. 达到最大重连次数则停止,触发 `maxReconnectAttemptsReached` 事件 +5. 延迟后尝试重新连接 +6. 连接成功则重置重连计数器,触发 `open` 事件 +7. 连接失败则触发 `reconnectFailed` 事件并继续尝试重连 + +### 指数退避算法 + +当启用指数退避时,重连延迟按以下公式计算: + +``` +delay = initialDelay * 2^(attempts - 1) +``` + +实际延迟取上述值与 `maxReconnectDelay` 的较小值。 + +例如,初始延迟为 1000ms 时: +- 第 1 次重连:1000ms (1s) +- 第 2 次重连:2000ms (2s) +- 第 3 次重连:4000ms (4s) +- 第 4 次重连:8000ms (8s) +- 第 5 次重连:16000ms (16s) +- 第 6 次重连:30000ms (30s,达到上限) + +## 完整示例 + +```typescript +import { RemoteApp } from '@kevisual/remote-app'; + +class MyService { + private remoteApp: RemoteApp; + + constructor() { + this.remoteApp = new RemoteApp({ + url: 'https://kevisual.cn/ws/proxy', + id: 'my-service', + token: process.env.REMOTE_TOKEN, + maxReconnectAttempts: 10, + reconnectDelay: 1000, + maxReconnectDelay: 30000 + }); + + this.setupEventListeners(); + } + + private setupEventListeners() { + this.remoteApp.on('open', () => { + console.log('远程连接已建立'); + this.remoteApp.listenProxy(); + }); + + this.remoteApp.on('close', () => { + console.log('远程连接已断开,正在尝试重连...'); + }); + + this.remoteApp.on('maxReconnectAttemptsReached', () => { + console.error('重连失败,请检查网络连接或服务状态'); + // 可以触发告警或通知管理员 + }); + } + + async connect() { + const connected = await this.remoteApp.isConnect(); + if (connected) { + console.log('连接成功'); + } else { + console.error('连接超时'); + } + } + + disconnect() { + this.remoteApp.disconnect(); + } +} + +const service = new MyService(); +service.connect(); +``` + +## License + +MIT diff --git a/assistant/src/module/remote-app/package-lock.json b/assistant/src/module/remote-app/package-lock.json new file mode 100644 index 0000000..d708f0b --- /dev/null +++ b/assistant/src/module/remote-app/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "@kevisual/remote-app", + "version": "0.0.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@kevisual/remote-app", + "version": "0.0.2", + "license": "MIT" + } + } +} diff --git a/assistant/src/module/remote-app/package.json b/assistant/src/module/remote-app/package.json index 9ae5408..78c3e93 100644 --- a/assistant/src/module/remote-app/package.json +++ b/assistant/src/module/remote-app/package.json @@ -1,20 +1,28 @@ { "name": "@kevisual/remote-app", - "version": "0.0.1", + "version": "0.0.4", "description": "", - "main": "remote-app.ts", + "main": "dist/app.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "build": "code-builder build -e remote-app.ts --dts" }, "keywords": [], "files": [ + "dist", "remote-app.ts" ], "publishConfig": { "access": "public" }, + "exports": { + ".": "./dist/app.js" + }, + "devDependencies": { + "eventemitter3": "^5.0", + "@kevisual/router": "^0.0.70" + }, "author": "abearxiong (https://www.xiongxiao.me)", "license": "MIT", - "packageManager": "pnpm@10.26.0", + "packageManager": "pnpm@10.28.2", "type": "module" } \ No newline at end of file diff --git a/assistant/src/module/remote-app/remote-app.ts b/assistant/src/module/remote-app/remote-app.ts index 874d4e6..b334910 100644 --- a/assistant/src/module/remote-app/remote-app.ts +++ b/assistant/src/module/remote-app/remote-app.ts @@ -6,6 +6,16 @@ type RemoteAppOptions = { token?: string; emitter?: EventEmitter; id?: string; + /** 是否启用自动重连,默认 true */ + autoReconnect?: boolean; + /** 最大重连次数,默认 Infinity */ + maxReconnectAttempts?: number; + /** 初始重连延迟(毫秒),默认 1000 */ + reconnectDelay?: number; + /** 重连延迟最大值(毫秒),默认 30000 */ + maxReconnectDelay?: number; + /** 是否启用指数退避,默认 true */ + enableBackoff?: boolean; }; /** * 远程共享地址类似:https://kevisual.cn/ws/proxy @@ -19,6 +29,15 @@ export class RemoteApp { ws: WebSocket; remoteIsConnected: boolean; isError: boolean = false; + // 重连相关属性 + autoReconnect: boolean; + maxReconnectAttempts: number; + reconnectDelay: number; + maxReconnectDelay: number; + enableBackoff: boolean; + reconnectAttempts: number = 0; + reconnectTimer: NodeJS.Timeout | null = null; + isManuallyClosed: boolean = false; constructor(opts?: RemoteAppOptions) { this.mainApp = opts?.app; const token = opts.token; @@ -32,6 +51,12 @@ export class RemoteApp { _url.searchParams.set('id', id); this.url = _url.toString(); this.id = id; + // 初始化重连相关配置 + this.autoReconnect = opts?.autoReconnect ?? true; + this.maxReconnectAttempts = opts?.maxReconnectAttempts ?? Infinity; + this.reconnectDelay = opts?.reconnectDelay ?? 1000; + this.maxReconnectDelay = opts?.maxReconnectDelay ?? 30000; + this.enableBackoff = opts?.enableBackoff ?? true; this.init(); } async isConnect(): Promise { @@ -39,6 +64,11 @@ export class RemoteApp { if (this.isConnected) { return true; } + // 如果正在进行重连,等待连接成功 + if (this.reconnectTimer !== null) { + console.log(`远程应用 ${this.id} 正在重连中...`); + } + // 等待连接成功(支持初次连接和重连场景) return new Promise((resolve) => { const timeout = setTimeout(() => { resolve(false); @@ -90,12 +120,84 @@ export class RemoteApp { this.ws = ws; } onOpen() { + this.isError = false; + this.reconnectAttempts = 0; + // 清除可能存在的重连定时器 + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } this.emitter.emit('open', this.id); } onClose() { console.log('远程应用关闭:', this.id); - this.emitter.emit('close', this.id); this.isConnected = false; + this.emitter.emit('close', this.id); + // 触发自动重连逻辑 + if (this.autoReconnect && !this.isManuallyClosed) { + this.scheduleReconnect(); + } + } + /** 计算下一次重连延迟 */ + calculateReconnectDelay(): number { + if (!this.enableBackoff) { + return this.reconnectDelay; + } + // 指数退避算法:delay = initialDelay * 2^(attempts - 1) + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts); + return Math.min(delay, this.maxReconnectDelay); + } + /** 安排重连 */ + scheduleReconnect() { + // 检查是否达到最大重连次数 + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error(`远程应用 ${this.id} 已达到最大重连次数 ${this.maxReconnectAttempts},停止重连`); + this.emitter.emit('maxReconnectAttemptsReached', this.id); + return; + } + // 清除可能存在的定时器 + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + } + const delay = this.calculateReconnectDelay(); + this.reconnectAttempts++; + console.log(`远程应用 ${this.id} 将在 ${delay}ms 后尝试第 ${this.reconnectAttempts} 次重连`); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + try { + this.init(); + } catch (error) { + console.error(`远程应用 ${this.id} 重连失败:`, error); + this.emitter.emit('reconnectFailed', { id: this.id, attempt: this.reconnectAttempts, error }); + // 重连失败后继续尝试重连 + this.scheduleReconnect(); + } + }, delay); + } + /** 手动关闭连接,停止自动重连 */ + disconnect() { + this.isManuallyClosed = true; + this.autoReconnect = false; + // 清除重连定时器 + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + // 关闭 WebSocket + if (this.ws) { + this.ws.close(); + } + } + /** 手动重连 */ + reconnect() { + this.isManuallyClosed = false; + this.reconnectAttempts = 0; + // 清除可能存在的定时器 + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.init(); } onMessage(data: any) { this.emitter.emit('message', data); @@ -105,7 +207,7 @@ export class RemoteApp { this.isError = true; this.emitter.emit('error', error); } - on(event: 'open' | 'close' | 'message' | 'error', listener: (data: any) => void) { + on(event: 'open' | 'close' | 'message' | 'error' | 'maxReconnectAttemptsReached' | 'reconnectFailed', listener: (data: any) => void) { this.emitter.on(event, listener); return () => { this.emitter.off(event, listener);