From b3c5e7d68d3d9c9b78ee8ced0ca5ac52d2e700c4 Mon Sep 17 00:00:00 2001 From: xiongxiao Date: Sun, 21 Dec 2025 02:39:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20RemoteApp=20?= =?UTF-8?q?=E7=B1=BB=E4=BB=A5=E6=94=AF=E6=8C=81=E8=BF=9C=E7=A8=8B=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E5=92=8C=E9=87=8D=E8=BF=9E=E9=80=BB=E8=BE=91=EF=BC=9B=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=94=A8=E6=88=B7=E8=B7=AF=E7=94=B1=E4=BB=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=8E=B7=E5=8F=96=E7=94=A8=E6=88=B7=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../local-app-manager/assistant-app.ts | 61 +++++++ .../module/assistant/remote-app/remote-app.ts | 148 ---------------- assistant/src/module/remote-app/.npmrc | 2 + assistant/src/module/remote-app/package.json | 20 +++ assistant/src/module/remote-app/remote-app.ts | 165 ++++++++++++++++++ assistant/src/routes/user/index.ts | 13 ++ assistant/src/server.ts | 9 +- assistant/src/test/remote-app.ts | 48 +++-- 8 files changed, 300 insertions(+), 166 deletions(-) delete mode 100644 assistant/src/module/assistant/remote-app/remote-app.ts create mode 100644 assistant/src/module/remote-app/.npmrc create mode 100644 assistant/src/module/remote-app/package.json create mode 100644 assistant/src/module/remote-app/remote-app.ts 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 bc62feb..052d60d 100644 --- a/assistant/src/module/assistant/local-app-manager/assistant-app.ts +++ b/assistant/src/module/assistant/local-app-manager/assistant-app.ts @@ -5,9 +5,14 @@ import path from 'node:path'; import fs from 'node:fs'; import glob from 'fast-glob'; import type { App } from '@kevisual/router'; +import { RemoteApp } from '@/module/remote-app/remote-app.ts'; +import { logger } from '@/module/logger.ts'; export class AssistantApp extends Manager { config: AssistantConfig; pagesPath: string; + remoteIsConnected = false; + attemptedConnectTimes = 0; + remoteApp: RemoteApp | null = null; constructor(config: AssistantConfig, mainApp?: App) { config.checkMounted(); const appsPath = config?.configPath?.appsDir || path.join(process.cwd(), 'apps'); @@ -65,4 +70,60 @@ export class AssistantApp extends Manager { }); return pagesParse; } + + async initRemoteApp() { + const config = this.config.getConfig(); + const share = config?.share; + if (share && share.enabled !== false) { + const token = config?.token; + const url = new URL(share.url || 'https://kevisual.cn/ws/proxy'); + const id = config?.app?.id; + if (token && url && id) { + const remoteApp = new RemoteApp({ + url: url.toString(), + token, + id, + app: this.mainApp, + }); + const isConnect = await remoteApp.isConnect(); + if (isConnect) { + remoteApp.listenProxy(); + this.remoteIsConnected = true; + remoteApp.emitter.once('close', () => { + setTimeout(() => { + if (remoteApp.isError) { + console.error('远程应用发生错误,不重连'); + } else { + this.reconnectRemoteApp(); + } + }, 5 * 1000); // 第一次断开5秒后重连 + }); + logger.debug('链接到了远程应用服务器'); + } else { + console.log('Not connected to remote app server'); + } + this.remoteApp = remoteApp; + } else { + // + } + } + } + async reconnectRemoteApp() { + console.log('重新连接到远程应用服务器...', this.attemptedConnectTimes); + const remoteApp = this.remoteApp;; + if (remoteApp) { + remoteApp.init(); + this.attemptedConnectTimes += 1; + const isConnect = await remoteApp.isConnect(); + if (isConnect) { + remoteApp.listenProxy(); + this.attemptedConnectTimes = 0; + console.log('重新连接到了远程应用服务器'); + } else { + setTimeout(() => { + this.reconnectRemoteApp(); + }, 30 * 1000); // 30秒后重连 + } + } + } } diff --git a/assistant/src/module/assistant/remote-app/remote-app.ts b/assistant/src/module/assistant/remote-app/remote-app.ts deleted file mode 100644 index 2e2cc9d..0000000 --- a/assistant/src/module/assistant/remote-app/remote-app.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { AssistantConfig } from '@/module/assistant/index.ts'; -import { WebSocket } from 'ws'; -import type { App } from '@kevisual/router'; -import { EventEmitter } from 'eventemitter3'; -import { logger } from '@/module/logger.ts'; -type RemoteAppOptions = { - app?: App; - assistantConfig?: AssistantConfig; - emitter?: EventEmitter; -}; -export class RemoteApp { - mainApp: App; - assistantConfig: AssistantConfig; - url: string; - name: string; - enabled: boolean; - emitter: EventEmitter; - isConnected: boolean; - ws: WebSocket; - constructor(opts?: RemoteAppOptions) { - this.mainApp = opts?.app; - this.assistantConfig = opts?.assistantConfig; - const config = this.assistantConfig?.getConfig(); - const share = config?.share; - const token = config?.token; - const app = config?.app || {}; - const name = app.id; - this.emitter = opts?.emitter || new EventEmitter(); - if (share) { - const { url, enabled } = share; - const _url = new URL(url); - if (token) { - _url.searchParams.set('token', token); - } - _url.searchParams.set('id', app.id); - this.url = _url.toString(); - this.name = name; - this.enabled = enabled ?? false; - if (this.enabled) { - this.init(); - } - } - } - async isConnect(): Promise { - const that = this; - if (this.isConnected) { - return true; - } - if (!this.enabled) { - return false; - } - return new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve(false); - that.emitter.off('open', listenOnce); - }, 5000); - const listenOnce = () => { - clearTimeout(timeout); - that.isConnected = true; - resolve(true); - }; - that.emitter.once('open', listenOnce); - }); - } - getWsURL(url: string) { - const { protocol } = new URL(url); - const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:'; - const wsURL = url.toString().replace(protocol, wsProtocol); - return wsURL; - } - async init() { - if (!this.url) { - throw new Error('No url provided for remote app'); - } - if (!this.name) { - throw new Error('No name provided for remote app'); - } - console.log('Connecting to remote app:', this.name, this.url, this.getWsURL(this.url)); - const ws = new WebSocket(this.getWsURL(this.url), { - rejectUnauthorized: true, - }); - const that = this; - ws.on('open', that.onOpen.bind(that)); - ws.on('close', that.onClose.bind(that)); - ws.on('message', that.onMessage.bind(that)); - ws.on('error', that.onError.bind(that)); - this.ws = ws; - } - onOpen() { - this.emitter.emit('open', this.name); - } - onClose() { - this.emitter.emit('close', this.name); - } - onMessage(data: any) { - console.log('Message from remote app:', this.name, data.toString()); - this.emitter.emit('message', data); - } - onError(error: any) { - console.error('Error in remote app:', this.name, error); - this.emitter.emit('error', error); - } - on(event: 'open' | 'close' | 'message' | 'error', listener: (data: any) => void) { - this.emitter.on(event, listener); - return () => { - this.emitter.off(event, listener); - }; - } - - sendData(data: any) { } - json(data: any) { - this.ws.send(JSON.stringify(data)); - } - listenProxy() { - const remoteApp = this; - const app = this.mainApp; - const listenFn = async (event: any) => { - const data = event.toString(); - logger.debug('Received message:', data); - const body = JSON.parse(data); - const message = body.data || {}; - if (body?.type !== 'proxy') return; - if (!body.id) { - remoteApp.json({ - id: body.id, - data: { - code: 400, - message: 'id is required', - }, - }); - return; - } - const res = await app.call(message); - remoteApp.json({ - id: body.id, - data: { - code: res.code, - data: res.body, - message: res.message, - }, - }); - }; - remoteApp.emitter.on('message', listenFn); - return () => { - remoteApp.emitter.off('message', listenFn); - }; - } -} diff --git a/assistant/src/module/remote-app/.npmrc b/assistant/src/module/remote-app/.npmrc new file mode 100644 index 0000000..7446745 --- /dev/null +++ b/assistant/src/module/remote-app/.npmrc @@ -0,0 +1,2 @@ +//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN} +//registry.npmjs.org/:_authToken=${NPM_TOKEN} \ No newline at end of file diff --git a/assistant/src/module/remote-app/package.json b/assistant/src/module/remote-app/package.json new file mode 100644 index 0000000..9ae5408 --- /dev/null +++ b/assistant/src/module/remote-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "@kevisual/remote-app", + "version": "0.0.1", + "description": "", + "main": "remote-app.ts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "files": [ + "remote-app.ts" + ], + "publishConfig": { + "access": "public" + }, + "author": "abearxiong (https://www.xiongxiao.me)", + "license": "MIT", + "packageManager": "pnpm@10.26.0", + "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 new file mode 100644 index 0000000..c31b532 --- /dev/null +++ b/assistant/src/module/remote-app/remote-app.ts @@ -0,0 +1,165 @@ +import type { App } from '@kevisual/router'; +import { EventEmitter } from 'eventemitter3'; +type RemoteAppOptions = { + app?: App; + url?: string; + token?: string; + emitter?: EventEmitter; + id?: string; +}; +export class RemoteApp { + mainApp: App; + url: string; + id: string; + emitter: EventEmitter; + isConnected: boolean; + ws: WebSocket; + remoteIsConnected: boolean; + isError: boolean = false; + constructor(opts?: RemoteAppOptions) { + this.mainApp = opts?.app; + const token = opts.token; + const url = opts.url; + const id = opts.id; + this.emitter = opts?.emitter || new EventEmitter(); + const _url = new URL(url); + if (token) { + _url.searchParams.set('token', token); + } + _url.searchParams.set('id', id); + this.url = _url.toString(); + this.id = id; + this.init(); + } + async isConnect(): Promise { + const that = this; + if (this.isConnected) { + return true; + } + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(false); + that.emitter.off('open', listenOnce); + }, 5000); + const listenOnce = () => { + clearTimeout(timeout); + that.isConnected = true; + that.remoteIsConnected = true; + resolve(true); + }; + that.emitter.once('open', listenOnce); + }); + } + getWsURL(url: string) { + const { protocol } = new URL(url); + const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:'; + const wsURL = url.toString().replace(protocol, wsProtocol); + return wsURL; + } + async init() { + if (!this.url) { + throw new Error('No url provided for remote app'); + } + if (!this.id) { + throw new Error('No id provided for remote app'); + } + this.isError = false; + const ws = new WebSocket(this.getWsURL(this.url)); + const that = this; + ws.onopen = function () { + that.isConnected = true; + that.onOpen(); + }; + ws.onclose = function () { + that.isConnected = false; + that.onClose(); + } + ws.onmessage = function (event) { + that.onMessage(event.data); + } + ws.onerror = function (error) { + that.onError(error); + } + this.ws = ws; + } + onOpen() { + this.emitter.emit('open', this.id); + } + onClose() { + console.log('远程应用关闭:', this.id); + this.emitter.emit('close', this.id); + this.isConnected = false; + } + onMessage(data: any) { + this.emitter.emit('message', data); + } + onError(error: any) { + console.error('远程应用错误:', this.id, error); + this.isError = true; + this.emitter.emit('error', error); + } + on(event: 'open' | 'close' | 'message' | 'error', listener: (data: any) => void) { + this.emitter.on(event, listener); + return () => { + this.emitter.off(event, listener); + }; + } + + sendData(data: any) { } + json(data: any) { + this.ws.send(JSON.stringify(data)); + } + listenProxy() { + const remoteApp = this; + const app = this.mainApp; + const listenFn = async (event: any) => { + try { + const data = event.toString(); + const body = JSON.parse(data); + const message = body.data || {}; + if (body?.code === 401) { + console.error('远程应用认证失败,请检查 token 是否正确'); + this.isError = true; + return; + } + if (body?.type !== 'proxy') return; + if (!body.id) { + remoteApp.json({ + id: body.id, + data: { + code: 400, + message: 'id is required', + }, + }); + return; + + } + const res = await app.call(message); + remoteApp.json({ + id: body.id, + data: { + code: res.code, + data: res.body, + message: res.message, + }, + }); + } catch (error) { + console.error('处理远程代理请求出错:', error); + } + }; + remoteApp.json({ + id: this.id, + type: 'registryClient' + }); + remoteApp.emitter.on('message', listenFn); + const closeMessage = () => { + remoteApp.emitter.off('message', listenFn); + } + remoteApp.emitter.once('close', () => { + closeMessage(); + }); + return () => { + closeMessage(); + }; + } +} diff --git a/assistant/src/routes/user/index.ts b/assistant/src/routes/user/index.ts index 6b3f344..f2c2606 100644 --- a/assistant/src/routes/user/index.ts +++ b/assistant/src/routes/user/index.ts @@ -67,3 +67,16 @@ app.route({ ctx.body = responseData.data; }).addTo(app); + +app.route({ + path: 'user', + key: 'me' +}).define(async (ctx) => { + const token = ctx.query.token; + const res = await assistantConfig.query.post({ + path: 'user', + key: 'me', + token, + }); + ctx.forward(res); +}).addTo(app); \ No newline at end of file diff --git a/assistant/src/server.ts b/assistant/src/server.ts index bbda4df..b8f9d8d 100644 --- a/assistant/src/server.ts +++ b/assistant/src/server.ts @@ -45,6 +45,7 @@ export const runServer = async (port: number = 51015, listenPath = '127.0.0.1') manager.load({ runtime: 'client' }).then(() => { console.log('Assistant App Loaded'); }); + manager.initRemoteApp() }, 1000); return { app, @@ -83,7 +84,7 @@ program // 构建 pm2 命令字符串 let pm2Command = `pm2 start ${escapePath(runPath)} --interpreter ${escapePath(interpreterPath)} --name ${name} -- -s`; - + if (port) { pm2Command += ` -p ${port}`; } @@ -104,7 +105,7 @@ program shell: true, windowsHide: false, }); - + if (result.error) { console.error(chalk.red('Error starting server:'), result.error.message); console.log(chalk.yellow('\n提示: 请检查:')); @@ -113,14 +114,14 @@ program console.log(' 3. 尝试手动执行:', pm2Command); process.exit(1); } - + if (result.status !== 0) { console.error(chalk.red(`PM2 exited with code ${result.status}`)); console.log(chalk.yellow('\n查看详细日志:'), `pm2 logs ${name}`); console.log(chalk.yellow('查看进程状态:'), 'pm2 list'); process.exit(result.status || 1); } - + console.log(chalk.green('✓ 以守护进程方式运行')); console.log(chalk.gray('查看日志:'), `pm2 logs ${name}`); } catch (error) { diff --git a/assistant/src/test/remote-app.ts b/assistant/src/test/remote-app.ts index 36bcb0c..9a152da 100644 --- a/assistant/src/test/remote-app.ts +++ b/assistant/src/test/remote-app.ts @@ -1,22 +1,42 @@ -import { logger } from '@/module/logger.ts'; import { assistantConfig, app } from '../app.ts'; -import { WebSocket } from 'ws'; import '../routes/index.ts'; -import { RemoteApp } from '@/module/assistant/remote-app/remote-app.ts'; +import { RemoteApp } from '@/module/remote-app/remote-app.ts'; const main = async () => { assistantConfig.checkMounted(); - const remoteApp = new RemoteApp({ - assistantConfig, - app, - }); - const connect = await remoteApp.isConnect(); - if (connect) { - console.log('Connected to proxy server'); - remoteApp.listenProxy(); - } else { - console.log('Not connected to proxy server'); + const config = assistantConfig?.getConfig(); + const share = config?.share; + if (share && share.enabled !== false) { + const token = config?.token; + const url = new URL(share.url || 'https://kevisual.cn/ws/proxy'); + const id = config?.app?.id; + if (!id) { + console.error('App ID is required for remote app connection.'); + return; + } + if (!token) { + console.error('Token is required for remote app connection.'); + return; + } + const remoteApp = new RemoteApp({ + url: url.toString(), + token, + id, + app, + }); + const connect = await remoteApp.isConnect(); + if (connect) { + console.log('Connected to proxy server'); + remoteApp.listenProxy(); + remoteApp.emitter.on('message', (event) => { + const _msg = event.toString(); + console.log('Received message from remote app:', _msg); + }); + } else { + console.log('Not connected to proxy server'); + } } + }; -// main(); +main();