From 7f7ea796894b84dcf2e8a53d1c2ff642c8d7cf06 Mon Sep 17 00:00:00 2001 From: abearxiong Date: Mon, 2 Feb 2026 21:22:49 +0800 Subject: [PATCH] feat: update package version and dependencies; add ReconnectingWebSocket for automatic reconnection --- package.json | 22 ++--- pnpm-lock.yaml | 79 +++++++++-------- rollup.config.js | 23 +++++ src/server/reconnect-ws.ts | 170 +++++++++++++++++++++++++++++++++++++ src/ws.ts | 65 ++++++++++++++ 5 files changed, 308 insertions(+), 51 deletions(-) create mode 100644 src/server/reconnect-ws.ts create mode 100644 src/ws.ts diff --git a/package.json b/package.json index b84efbd..9b54397 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package", "name": "@kevisual/router", - "version": "0.0.66", + "version": "0.0.67", "description": "", "type": "module", "main": "./dist/router.js", @@ -24,17 +24,18 @@ "packageManager": "pnpm@10.28.2", "devDependencies": { "@kevisual/context": "^0.0.4", + "@kevisual/dts": "^0.0.3", "@kevisual/js-filter": "^0.0.5", "@kevisual/local-proxy": "^0.0.8", - "@kevisual/query": "^0.0.38", - "@kevisual/use-config": "^1.0.28", - "@opencode-ai/plugin": "^1.1.47", + "@kevisual/query": "^0.0.39", + "@kevisual/use-config": "^1.0.30", + "@opencode-ai/plugin": "^1.1.48", "@rollup/plugin-alias": "^6.0.0", "@rollup/plugin-commonjs": "29.0.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-typescript": "^12.3.0", "@types/bun": "^1.3.8", - "@types/node": "^25.1.0", + "@types/node": "^25.2.0", "@types/send": "^1.2.1", "@types/ws": "^8.18.1", "@types/xml2js": "^0.4.14", @@ -52,16 +53,14 @@ "typescript": "^5.9.3", "ws": "npm:@kevisual/ws", "xml2js": "^0.6.2", - "zod": "^4.3.6" + "zod": "^4.3.6", + "hono": "^4.11.7" }, "repository": { "type": "git", "url": "git+https://github.com/abearxiong/kevisual-router.git" }, - "dependencies": { - "@kevisual/dts": "^0.0.3", - "hono": "^4.11.7" - }, + "dependencies": {}, "publishConfig": { "access": "public" }, @@ -88,6 +87,7 @@ "require": "./dist/router-define.js", "types": "./dist/router-define.d.ts" }, + "./ws": "./dist/ws.js", "./mod.ts": { "import": "./mod.ts", "require": "./mod.ts", @@ -102,4 +102,4 @@ "require": "./src/modules/*" } } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 055bf5c..89e6abe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,17 +7,13 @@ settings: importers: .: - dependencies: - '@kevisual/dts': - specifier: ^0.0.3 - version: 0.0.3(typescript@5.9.3) - hono: - specifier: ^4.11.7 - version: 4.11.7 devDependencies: '@kevisual/context': specifier: ^0.0.4 version: 0.0.4 + '@kevisual/dts': + specifier: ^0.0.3 + version: 0.0.3(typescript@5.9.3) '@kevisual/js-filter': specifier: ^0.0.5 version: 0.0.5 @@ -25,14 +21,14 @@ importers: specifier: ^0.0.8 version: 0.0.8 '@kevisual/query': - specifier: ^0.0.38 - version: 0.0.38 + specifier: ^0.0.39 + version: 0.0.39 '@kevisual/use-config': - specifier: ^1.0.28 - version: 1.0.28(dotenv@17.2.3) + specifier: ^1.0.30 + version: 1.0.30(dotenv@17.2.3) '@opencode-ai/plugin': - specifier: ^1.1.47 - version: 1.1.47 + specifier: ^1.1.48 + version: 1.1.48 '@rollup/plugin-alias': specifier: ^6.0.0 version: 6.0.0(rollup@4.57.1) @@ -49,8 +45,8 @@ importers: specifier: ^1.3.8 version: 1.3.8 '@types/node': - specifier: ^25.1.0 - version: 25.1.0 + specifier: ^25.2.0 + version: 25.2.0 '@types/send': specifier: ^1.2.1 version: 1.2.1 @@ -66,6 +62,9 @@ importers: fast-glob: specifier: ^3.3.3 version: 3.3.3 + hono: + specifier: ^4.11.7 + version: 4.11.7 nanoid: specifier: ^5.1.6 version: 5.1.6 @@ -86,7 +85,7 @@ importers: version: 9.5.4(typescript@5.9.3)(webpack@5.104.1) ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.1.0)(typescript@5.9.3) + version: 10.9.2(@types/node@25.2.0)(typescript@5.9.3) tslib: specifier: ^2.8.1 version: 2.8.1 @@ -117,7 +116,7 @@ importers: version: 1.1.1 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.1.0)(typescript@5.9.3) + version: 10.9.2(@types/node@25.2.0)(typescript@5.9.3) typescript: specifier: ^5.5.4 version: 5.9.3 @@ -327,11 +326,11 @@ packages: '@kevisual/local-proxy@0.0.8': resolution: {integrity: sha512-VX/P+6/Cc8ruqp34ag6gVX073BchUmf5VNZcTV/6MJtjrNE76G8V6TLpBE8bywLnrqyRtFLIspk4QlH8up9B5Q==} - '@kevisual/query@0.0.38': - resolution: {integrity: sha512-bfvbSodsZyMfwY+1T2SvDeOCKsT/AaIxlVe0+B1R/fNhlg2MDq2CP0L9HKiFkEm+OXrvXcYDMKPUituVUM5J6Q==} + '@kevisual/query@0.0.39': + resolution: {integrity: sha512-3UEPBIvtdykNkrby3hvrgrHdgd17Uq+Pnr4zs+JBzATkU2eKaOqtTUJqdyIEwuySCwzGTxrnlUzWP4tziDQDLQ==} - '@kevisual/use-config@1.0.28': - resolution: {integrity: sha512-ngF+LDbjxpXWrZNmnShIKF/jPpAa+ezV+DcgoZIIzHlRnIjE+rr9sLkN/B7WJbiH9C/j1tQXOILY8ujBqILrow==} + '@kevisual/use-config@1.0.30': + resolution: {integrity: sha512-kPdna0FW/X7D600aMdiZ5UTjbCo6d8d4jjauSc8RMmBwUU6WliFDSPUNKVpzm2BsDX5Nth1IXFPYMqH+wxqAmw==} peerDependencies: dotenv: ^17 @@ -351,11 +350,11 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@opencode-ai/plugin@1.1.47': - resolution: {integrity: sha512-gNMPz72altieDfLhUw3VAT1xbduKi3w3wZ57GLeS7qU9W474HdvdIiLBnt2Xq3U7Ko0/0tvK3nzCker6IIDqmQ==} + '@opencode-ai/plugin@1.1.48': + resolution: {integrity: sha512-KkaSMevXmz7tOwYDMJeWiXE5N8LmRP18qWI5Xhv3+c+FdGPL+l1hQrjSgyv3k7Co7qpCyW3kAUESBB7BzIOl2w==} - '@opencode-ai/sdk@1.1.47': - resolution: {integrity: sha512-s3PBHwk1sP6Zt/lJxIWSBWZ1TnrI1nFxSP97LCODUytouAQgbygZ1oDH7O2sGMBEuGdA8B1nNSPla0aRSN3IpA==} + '@opencode-ai/sdk@1.1.48': + resolution: {integrity: sha512-j5/79X45fUPWVD2Ffm/qvwLclDCdPeV+TYMDrm9to0p4pmzhmeKevCsyiRdLg0o0HE3AFRUnOo2rdO9NetN79A==} '@rollup/plugin-alias@6.0.0': resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==} @@ -580,8 +579,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@25.1.0': - resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==} + '@types/node@25.2.0': + resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -1342,11 +1341,11 @@ snapshots: '@kevisual/local-proxy@0.0.8': {} - '@kevisual/query@0.0.38': + '@kevisual/query@0.0.39': dependencies: tslib: 2.8.1 - '@kevisual/use-config@1.0.28(dotenv@17.2.3)': + '@kevisual/use-config@1.0.30(dotenv@17.2.3)': dependencies: '@kevisual/load': 0.0.6 dotenv: 17.2.3 @@ -1365,12 +1364,12 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@opencode-ai/plugin@1.1.47': + '@opencode-ai/plugin@1.1.48': dependencies: - '@opencode-ai/sdk': 1.1.47 + '@opencode-ai/sdk': 1.1.48 zod: 4.1.8 - '@opencode-ai/sdk@1.1.47': {} + '@opencode-ai/sdk@1.1.48': {} '@rollup/plugin-alias@6.0.0(rollup@4.57.1)': optionalDependencies: @@ -1528,7 +1527,7 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@25.1.0': + '@types/node@25.2.0': dependencies: undici-types: 7.16.0 @@ -1536,15 +1535,15 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/ws@8.18.1': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/xml2js@0.4.14': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@webassemblyjs/ast@1.14.1': dependencies: @@ -1676,7 +1675,7 @@ snapshots: bun-types@1.3.8: dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 caniuse-lite@1.0.30001761: {} @@ -1861,7 +1860,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -2076,14 +2075,14 @@ snapshots: typescript: 5.9.3 webpack: 5.104.1 - ts-node@10.9.2(@types/node@25.1.0)(typescript@5.9.3): + ts-node@10.9.2(@types/node@25.2.0)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 25.1.0 + '@types/node': 25.2.0 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 diff --git a/rollup.config.js b/rollup.config.js index 328f089..b00daed 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -148,4 +148,27 @@ export default [ }, plugins: [dts()], }, + { + input: 'src/ws.ts', + output: { + file: 'dist/ws.js', + format: 'es', + }, + external: ['ws'], + plugins: [ + resolve({ + // browser: true, + }), + commonjs(), + typescript(), + ], + }, + { + input: 'src/ws.ts', + output: { + file: 'dist/ws.d.ts', + format: 'es', + }, + plugins: [dts()], + }, ]; diff --git a/src/server/reconnect-ws.ts b/src/server/reconnect-ws.ts new file mode 100644 index 0000000..cfcc81f --- /dev/null +++ b/src/server/reconnect-ws.ts @@ -0,0 +1,170 @@ +import WebSocket from 'ws'; + +export type ReconnectConfig = { + /** + * 重连配置选项, 最大重试次数,默认无限 + */ + maxRetries?: number; + /** + * 重连配置选项, 重试延迟(ms),默认1000 + */ + retryDelay?: number; + /** + * 重连配置选项, 最大延迟(ms),默认30000 + */ + maxDelay?: number; + /** + * 重连配置选项, 退避倍数,默认2 + */ + backoffMultiplier?: number; +}; + +/** + * 一个支持自动重连的 WebSocket 客户端。 + * 在连接断开时会根据配置进行重连尝试,支持指数退避。 + */ +export class ReconnectingWebSocket { + private ws: WebSocket | null = null; + private url: string; + private config: Required; + 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, + }; + } + log(...args: any[]): void { + console.log('[ReconnectingWebSocket]', ...args); + } + error(...args: any[]): void { + console.error('[ReconnectingWebSocket]', ...args); + } + connect(): void { + if (this.ws?.readyState === WebSocket.OPEN) { + return; + } + + this.log(`正在连接到 ${this.url}...`); + this.ws = new WebSocket(this.url); + + this.ws.on('open', () => { + this.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) => { + this.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) => { + this.error('WebSocket 错误:', error.message); + this.errorHandlers.forEach(handler => handler(error)); + }); + } + + private scheduleReconnect(): void { + if (this.reconnectTimer) { + return; + } + + if (this.retryCount >= this.config.maxRetries) { + this.error(`已达到最大重试次数 (${this.config.maxRetries}),停止重连`); + return; + } + + // 计算延迟(指数退避) + const delay = Math.min( + this.config.retryDelay * Math.pow(this.config.backoffMultiplier, this.retryCount), + this.config.maxDelay + ); + + this.retryCount++; + this.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; + } + this.log('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 ws = new ReconnectingWebSocket('ws://localhost:51516/livecode/ws?id=test-live-app', { +// maxRetries: Infinity, // 无限重试 +// retryDelay: 1000, // 初始重试延迟 1 秒 +// maxDelay: 30000, // 最大延迟 30 秒 +// backoffMultiplier: 2, // 指数退避倍数 +// }); \ No newline at end of file diff --git a/src/ws.ts b/src/ws.ts new file mode 100644 index 0000000..7b6d9f7 --- /dev/null +++ b/src/ws.ts @@ -0,0 +1,65 @@ +import { ReconnectingWebSocket, ReconnectConfig } from "./server/reconnect-ws.ts"; + +export * from "./server/reconnect-ws.ts"; +import type { App } from "./app.ts"; + +export const handleCallWsApp = async (ws: ReconnectingWebSocket, app: App, message: any) => { + return handleCallApp((data: any) => { + ws.send(data); + }, app, message); +} +export const handleCallApp = async (send: (data: any) => void, app: App, message: any) => { + if (message.type === 'router' && message.id) { + const data = message?.data; + if (!message.id) { + console.error('Message id is required for router type'); + return; + } + if (!data) { + send({ + type: 'router', + id: message.id, + data: { code: 500, message: 'No data received' } + }); + return; + } + const { tokenUser, ...rest } = data || {}; + const res = await app.run(rest, { + state: { tokenUser }, + appId: app.appId, + }); + send({ + type: 'router', + id: message.id, + data: res + }); + } +} +export class Ws { + wsClient: ReconnectingWebSocket; + app: App; + showLog: boolean = true; + constructor(opts?: ReconnectConfig & { + url: string; + app: App; + showLog?: boolean; + handleMessage?: (ws: ReconnectingWebSocket, app: App, message: any) => void; + }) { + const { url, app, showLog = true, handleMessage = handleCallWsApp, ...rest } = opts; + this.wsClient = new ReconnectingWebSocket(url, rest); + this.app = app; + this.showLog = showLog; + this.wsClient.connect(); + const onMessage = async (data: any) => { + return handleMessage(this.wsClient, this.app, data); + } + this.wsClient.onMessage(onMessage); + } + send(data: any): boolean { + return this.wsClient.send(data); + } + log(...args: any[]): void { + if (this.showLog) + console.log('[Ws]', ...args); + } +} \ No newline at end of file