feat: 实现 RemoteApp 类以支持远程连接,添加初始化和重连逻辑;更新用户路由以支持获取用户信息

This commit is contained in:
2025-12-21 02:39:52 +08:00
parent 864766be4a
commit b3c5e7d68d
8 changed files with 300 additions and 166 deletions

View File

@@ -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秒后重连
}
}
}
}

View File

@@ -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<boolean> {
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);
};
}
}

View File

@@ -0,0 +1,2 @@
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
//registry.npmjs.org/:_authToken=${NPM_TOKEN}

View File

@@ -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 <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT",
"packageManager": "pnpm@10.26.0",
"type": "module"
}

View File

@@ -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<boolean> {
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();
};
}
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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 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({
assistantConfig,
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();