This commit is contained in:
熊潇 2025-04-26 03:15:11 +08:00
parent 9eb4d06939
commit bcc12209e0
28 changed files with 1708 additions and 126 deletions

View File

@ -6,3 +6,6 @@ dist
pack-dist pack-dist
assistant-app assistant-app
.env*
!.env*example

View File

@ -0,0 +1,4 @@
#!/usr/bin/env node
import { runParser } from '../dist/assistant-server.mjs';
runParser(process.argv);

View File

@ -0,0 +1,4 @@
#!/usr/bin/env node
import { runParser } from '../dist/assistant.mjs';
runParser(process.argv);

View File

@ -17,3 +17,17 @@ await Bun.build({
}, },
env: 'ENVISION_*', env: 'ENVISION_*',
}); });
await Bun.build({
target: 'node',
format: 'esm',
entrypoints: ['./src/server.ts'],
outdir: './dist',
naming: {
entry: 'assistan-server.mjs',
},
define: {
ENVISION_VERSION: JSON.stringify(pkg.version),
},
env: 'ENVISION_*',
});

View File

@ -10,7 +10,7 @@
], ],
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)", "author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT", "license": "MIT",
"packageManager": "pnpm@10.7.0", "packageManager": "pnpm@10.9.0",
"type": "module", "type": "module",
"files": [ "files": [
"dist", "dist",
@ -19,24 +19,36 @@
], ],
"scripts": { "scripts": {
"dev": "bun run src/run.ts ", "dev": "bun run src/run.ts ",
"dev:serve": "bun --watch src/serve.ts", "dev:server": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 bun --watch src/server.ts",
"build": "rimraf dist && bun run bun.config.mjs" "build": "rimraf dist && bun run bun.config.mjs"
}, },
"bin": {
"ev-assistant": "bin/assistant.js",
"ev-asst": "bin/assistant.js"
},
"devDependencies": { "devDependencies": {
"@kevisual/load": "^0.0.6", "@kevisual/load": "^0.0.6",
"@kevisual/local-app-manager": "^0.1.16",
"@kevisual/query": "0.0.17", "@kevisual/query": "0.0.17",
"@kevisual/query-login": "0.0.5", "@kevisual/query-login": "0.0.5",
"@kevisual/router": "^0.0.13", "@kevisual/router": "^0.0.13",
"@kevisual/use-config": "^1.0.11", "@kevisual/use-config": "^1.0.11",
"@types/bun": "^1.2.10", "@types/bun": "^1.2.10",
"@types/node": "^22.14.1", "@types/lodash-es": "^4.17.12",
"@types/node": "^22.15.2",
"@types/send": "^0.17.4", "@types/send": "^0.17.4",
"@types/ws": "^8.18.1",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"commander": "^13.1.0", "commander": "^13.1.0",
"inquirer": "^12.5.2", "cross-env": "^7.0.3",
"inquirer": "^12.6.0",
"lodash-es": "^4.17.21",
"nanoid": "^5.1.5",
"pino": "^9.6.0", "pino": "^9.6.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"send": "^1.2.0", "send": "^1.2.0",
"supports-color": "^10.0.0",
"ws": "npm:@kevisual/ws",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"engines": { "engines": {
@ -44,5 +56,11 @@
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
},
"dependencies": {
"dayjs": "^1.11.13"
},
"overrides": {
"ws": "npm:@kevisual/ws"
} }
} }

View File

@ -1,13 +1,7 @@
import { App } from '@kevisual/router'; import { App } from '@kevisual/router';
import { AssistantConfig } from '@/module/assistant/index.ts';
import { HttpsPem } from '@/module/assistant/https/sign.ts'; import { HttpsPem } from '@/module/assistant/https/sign.ts';
import path from 'node:path'; import { assistantConfig } from '@/config.ts';
export { assistantConfig };
export const configDir = path.resolve(process.env.assistantConfigDir || process.cwd());
export const assistantConfig = new AssistantConfig({
configDir,
init: true,
});
const httpsPem = new HttpsPem(assistantConfig); const httpsPem = new HttpsPem(assistantConfig);
export const app = new App({ export const app = new App({
serverOptions: { serverOptions: {

View File

@ -0,0 +1,68 @@
import { AssistantApp } from '@/module/assistant/index.ts';
import { program, Command, assistantConfig } from '@/program.ts';
const appManagerCommand = new Command('app-manager').alias('am').description('Manage Assistant Apps 管理本地的应用模块');
program.addCommand(appManagerCommand);
appManagerCommand
.command('list')
.description('List all installed apps')
.action(async () => {
const manager = new AssistantApp(assistantConfig);
await manager.loadConfig();
const showInfos = manager.getAllAppShowInfo();
console.log('Installed Apps:', showInfos);
});
appManagerCommand
.command('detect')
.description('Detect all installed apps')
.action(async () => {
const manager = new AssistantApp(assistantConfig);
await manager.loadConfig();
const showInfos = await manager.detectApp();
if (showInfos === true) {
const showInfos = manager.getAllAppShowInfo();
console.log('Installed Apps:', showInfos);
} else {
console.log('Install New Apps:', showInfos);
}
});
appManagerCommand
.command('start')
.argument('<app-key-name>', '应用的 key 名称')
.action(async (appKey: string) => {
const manager = new AssistantApp(assistantConfig);
await manager.loadConfig();
manager.start(appKey);
console.log('Start App:', appKey);
});
appManagerCommand
.command('stop')
.argument('<app-key-name>', '应用的 key 名称')
.action(async (appKey: string) => {
const manager = new AssistantApp(assistantConfig);
try {
await manager.loadConfig();
await manager.stop(appKey);
} catch (error) {
console.error(error);
}
console.log('Stop App:', appKey);
});
appManagerCommand
.command('restart')
.argument('<app-key-name>', '应用的 key 名称')
.action(async (appKey: string) => {
const manager = new AssistantApp(assistantConfig);
try {
await manager.loadConfig();
await manager.restart(appKey);
} catch (error) {
console.error(error);
}
console.log('Restart App:', appKey);
});

7
assistant/src/config.ts Normal file
View File

@ -0,0 +1,7 @@
import { AssistantConfig } from '@/module/assistant/index.ts';
export const configDir = AssistantConfig.detectConfigDir();
export const assistantConfig = new AssistantConfig({
configDir,
init: true,
});

View File

@ -1,5 +1,6 @@
import { program, runProgram } from '@/program.ts'; import { program, runProgram } from '@/program.ts';
import './command/init/index.ts'; import './command/init/index.ts';
import './command/app-manager/index.ts';
/** /**
* *

View File

@ -17,11 +17,12 @@ const configDir = createDir(path.join(homedir(), '.config/envision/assistant-app
export const initConfig = (configRootPath: string) => { export const initConfig = (configRootPath: string) => {
const configDir = createDir(path.join(configRootPath, 'assistant-app')); const configDir = createDir(path.join(configRootPath, 'assistant-app'));
const configPath = path.join(configDir, 'assistant-config.json'); const configPath = path.join(configDir, 'assistant-config.json');
const appConfigPath = path.join(configDir, 'assistant-app-config.json'); const pageConfigPath = path.join(configDir, 'assistant-page-config.json');
const appDir = createDir(path.join(configDir, 'frontend')); const pageDir = createDir(path.join(configDir, 'page'));
const serviceDir = createDir(path.join(configDir, 'services')); const appsDir = createDir(path.join(configDir, 'apps'));
const serviceConfigPath = path.join(serviceDir, 'assistant-service-config.json'); const appsConfigPath = path.join(appsDir, 'assistant-apps-config.json');
const appPidPath = path.join(configDir, 'assistant-app.pid'); const appPidPath = path.join(configDir, 'assistant-app.pid');
const envConfigPath = path.join(configDir, '.env');
return { return {
/** /**
* *
@ -34,30 +35,34 @@ export const initConfig = (configRootPath: string) => {
/** /**
* *
*/ */
serviceDir, appsDir,
/** /**
* assistant-service-config.json * assistant-service-config.json
*/ */
serviceConfigPath, appsConfigPath,
/** /**
* *
*/ */
appDir, pageDir,
/** /**
* , assistant-app-config.json * , assistant-page-config.json
*/ */
appConfigPath, pageConfigPath,
/** /**
* pid文件路径 * pid文件路径
*/ */
appPidPath, appPidPath,
/**
*
*/
envConfigPath,
}; };
}; };
export type ReturnInitConfigType = ReturnType<typeof initConfig>; export type ReturnInitConfigType = ReturnType<typeof initConfig>;
type AssistantConfigData = { type AssistantConfigData = {
pageApi?: string; // https://kevisual.silkyai.cn pageApi?: string; // https://kevisual.silkyai.cn
proxy?: { user: string; key: string; path: string }[]; proxy?: ProxyInfo[];
apiProxyList?: ProxyInfo[]; apiProxyList?: ProxyInfo[];
description?: string; description?: string;
}; };
@ -120,25 +125,25 @@ export class AssistantConfig {
* *
* @returns * @returns
*/ */
getAppConfig(): AppConfig { getPageConfig(): AppConfig {
const { appConfigPath } = this.configPath; const { pageConfigPath } = this.configPath;
if (!checkFileExists(appConfigPath)) { if (!checkFileExists(pageConfigPath)) {
return { return {
list: [], list: [],
}; };
} }
return JSON.parse(fs.readFileSync(appConfigPath, 'utf8')); return JSON.parse(fs.readFileSync(pageConfigPath, 'utf8'));
} }
setAppConfig(config?: AppConfig) { setAppConfig(config?: AppConfig) {
const _config = this.getAppConfig(); const _config = this.getPageConfig();
const _saveConfig = { ..._config, ...config }; const _saveConfig = { ..._config, ...config };
const { appConfigPath } = this.configPath; const { pageConfigPath } = this.configPath;
fs.writeFileSync(appConfigPath, JSON.stringify(_saveConfig, null, 2)); fs.writeFileSync(pageConfigPath, JSON.stringify(_saveConfig, null, 2));
return _saveConfig; return _saveConfig;
} }
assAppConfig(app: any) { assAppConfig(app: any) {
const config = this.getAppConfig(); const config = this.getPageConfig();
const assistantConfig = this.getCacheAssistantConfig(); const assistantConfig = this.getCacheAssistantConfig();
const _apps = config.list; const _apps = config.list;
const _proxy = assistantConfig.proxy || []; const _proxy = assistantConfig.proxy || [];
@ -166,7 +171,36 @@ export class AssistantConfig {
return config; return config;
} }
getAppList() { getAppList() {
return this.getAppConfig().list; return this.getPageConfig().list;
}
/**
* process.env.ASSISTANT_CONFIG_DIR || process.cwd()
* configDir是助手配置文件目录
* assistant-config.json
* assistant-page-config.json
* assistant-app.pid pid文件
* .env
* apps: 服务目录
* page: 应用目录
* pem: 证书目录
* @param configDir
*/
static detectConfigDir(configDir?: string) {
const checkConfigDir = path.resolve(configDir || process.env.ASSISTANT_CONFIG_DIR || process.cwd());
const configPath = path.join(checkConfigDir, 'assistant-app');
if (checkFileExists(configPath)) {
return path.join(checkConfigDir);
}
const lastConfigPath = path.join(checkConfigDir, '..', 'assistant-app');
if (checkFileExists(lastConfigPath)) {
return path.join(checkConfigDir, '..');
}
const lastConfigPath2 = path.join(checkConfigDir, '../..', 'assistant-app');
if (checkFileExists(lastConfigPath2)) {
return path.join(checkConfigDir, '../..');
}
// 如果没有找到助手配置文件目录,则返回当前目录, 执行默认创建助手配置文件目录
return checkConfigDir;
} }
} }

View File

@ -4,6 +4,8 @@ import fs from 'node:fs';
import { AssistantConfig } from '../config/index.ts'; import { AssistantConfig } from '../config/index.ts';
import { checkFileExists } from '../file/index.ts'; import { checkFileExists } from '../file/index.ts';
import { chalk } from '@/module/chalk.ts'; import { chalk } from '@/module/chalk.ts';
import dayjs from 'dayjs';
type Attributes = { type Attributes = {
name: string; name: string;
value: string; value: string;
@ -36,24 +38,91 @@ export class HttpsPem {
const pemPath = { const pemPath = {
key: path.join(pemDir, 'https-private-key.pem'), key: path.join(pemDir, 'https-private-key.pem'),
cert: path.join(pemDir, 'https-cert.pem'), cert: path.join(pemDir, 'https-cert.pem'),
config: path.join(pemDir, 'https-config.json'),
};
const writeCreate = (opts: { key: string; cert: string; data: { createTime: number; expireTime: number } }) => {
fs.writeFileSync(pemPath.key, opts.key);
fs.writeFileSync(pemPath.cert, opts.cert);
fs.writeFileSync(pemPath.config, JSON.stringify(opts.data, null, 2));
}; };
if (!checkFileExists(pemPath.key) || !checkFileExists(pemPath.cert)) { if (!checkFileExists(pemPath.key) || !checkFileExists(pemPath.cert)) {
const { key, cert } = this.createCert(); const { key, cert, data } = this.createCert();
fs.writeFileSync(pemPath.key, key); writeCreate({ key, cert, data });
fs.writeFileSync(pemPath.cert, cert); console.log(chalk.green('证书创建成功,浏览器需要导入当前证书'));
console.log(chalk.green('证书创建成功'))
return { return {
key, key,
cert, cert,
}; };
} }
if (!checkFileExists(pemPath.config)) {
const data = this.createExpireData();
fs.writeFileSync(pemPath.config, JSON.stringify(data, null, 2));
}
const key = fs.readFileSync(pemPath.key, 'utf-8'); const key = fs.readFileSync(pemPath.key, 'utf-8');
const cert = fs.readFileSync(pemPath.cert, 'utf-8'); const cert = fs.readFileSync(pemPath.cert, 'utf-8');
const config = fs.readFileSync(pemPath.config, 'utf-8');
let expireTime = 0;
try {
const data = JSON.parse(config);
expireTime = data.expireTime;
if (typeof expireTime !== 'number') {
throw new Error('expireTime is not a number');
}
} catch (error) {
console.log(chalk.red('证书配置文件损坏,重新生成证书'));
}
const now = new Date().getTime();
if (now > expireTime) {
this.removeCert();
const { key, cert, data } = this.createCert();
writeCreate({ key, cert, data });
console.log(chalk.green('证书更新成功, 浏览器需要重新导入当前证书'));
return {
key,
cert,
};
}
return { return {
key, key,
cert, cert,
}; };
} }
createExpireData() {
const expireTime = new Date().getTime() + 365 * 24 * 60 * 60 * 1000;
const expireDate = dayjs(expireTime).format('YYYY-MM-DD HH:mm:ss');
return {
description: '手动导入证书到浏览器, https-cert.pem文件, 具体使用教程访问 https://kevisual.cn/root/pem-docs/',
createTime: new Date().getTime(),
expireDate,
expireTime,
};
}
/*
*
*/
removeCert() {
const pemDir = this.getPemDir();
const pemPath = {
key: path.join(pemDir, 'https-private-key.pem'),
cert: path.join(pemDir, 'https-cert.pem'),
};
const oldPath = {
key: path.join(pemDir, 'https-private-key.pem.bak'),
cert: path.join(pemDir, 'https-cert.pem.bak'),
};
if (checkFileExists(pemPath.key)) {
fs.renameSync(pemPath.key, oldPath.key);
}
if (checkFileExists(pemPath.cert)) {
fs.renameSync(pemPath.cert, oldPath.cert);
}
}
/*
*
* @param attrs
* @param altNames
*/
createCert(attrs?: Attributes[], altNames?: AltNames[]) { createCert(attrs?: Attributes[], altNames?: AltNames[]) {
const attributes = attrs || []; const attributes = attrs || [];
const altNamesList = altNames || []; const altNamesList = altNames || [];
@ -71,9 +140,13 @@ export class HttpsPem {
], ],
altNamesList, altNamesList,
); );
const data = this.createExpireData();
return { return {
key, key,
cert, cert,
data: {
...data,
},
}; };
} }
} }

View File

@ -5,3 +5,5 @@ export * from './file/index.ts';
export * from './process/index.ts'; export * from './process/index.ts';
export * from './proxy/index.ts'; export * from './proxy/index.ts';
export * from './local-app-manager/index.ts';

View File

@ -0,0 +1,18 @@
import { Manager } from '@kevisual/local-app-manager/manager';
import { AssistantConfig } from '@/module/assistant/index.ts';
import path from 'node:path';
export class AssistantApp extends Manager {
config: AssistantConfig;
constructor(config: AssistantConfig) {
const appsPath = config.configPath?.appsDir || path.join(process.cwd(), 'apps');
const appsConfigPath = config.configPath?.appsConfigPath;
const configFimename = path.basename(appsConfigPath || '');
super({
appsPath,
configFilename: configFimename,
});
this.config = config;
}
}

View File

@ -0,0 +1,3 @@
export { Manager } from '@kevisual/local-app-manager/manager';
export { Pm2Manager } from '@kevisual/local-app-manager/pm2';
export { AssistantApp } from './assistant-app.ts';

View File

@ -4,6 +4,7 @@ import fs from 'node:fs';
import path from 'path'; import path from 'path';
import { ProxyInfo } from './proxy.ts'; import { ProxyInfo } from './proxy.ts';
import { checkFileExists } from '../file/index.ts'; import { checkFileExists } from '../file/index.ts';
import { log } from '@/module/logger.ts';
export const fileProxy = (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => { export const fileProxy = (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
// url开头的文件 // url开头的文件
@ -11,6 +12,9 @@ export const fileProxy = (req: http.IncomingMessage, res: http.ServerResponse, p
const [user, key, _info] = url.pathname.split('/'); const [user, key, _info] = url.pathname.split('/');
const pathname = url.pathname.slice(1); const pathname = url.pathname.slice(1);
const { indexPath = '', target = '', rootPath = process.cwd() } = proxyApi; const { indexPath = '', target = '', rootPath = process.cwd() } = proxyApi;
if (!indexPath) {
return res.end('Not Found indexPath');
}
try { try {
// 检测文件是否存在如果文件不存在则返回404 // 检测文件是否存在如果文件不存在则返回404
let filePath = ''; let filePath = '';
@ -23,7 +27,7 @@ export const fileProxy = (req: http.IncomingMessage, res: http.ServerResponse, p
filePath = path.join(rootPath, target, indexPath); filePath = path.join(rootPath, target, indexPath);
exist = checkFileExists(filePath, true); exist = checkFileExists(filePath, true);
} }
console.log('filePath', filePath, exist); log.debug('filePath', { filePath, exist });
if (!exist) { if (!exist) {
res.statusCode = 404; res.statusCode = 404;

View File

@ -28,10 +28,13 @@ export const createApiProxy = (api: string, paths: string[] = ['/api', '/v1']) =
return pathList; return pathList;
}; };
export const apiProxy = (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => { export const httpProxy = (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
const { target } = proxyApi; const { target } = proxyApi;
const _u = new URL(req.url, `${target}`); const _u = new URL(req.url, `${target}`);
console.log('proxyApi', { url: req.url, target: _u.href }); // console.log('proxyApi', { url: req.url, target: _u.href });
if (proxyApi.pathname) {
_u.pathname = _u.pathname.replace(proxyApi.path, proxyApi.pathname);
}
// 设置代理请求的目标 URL 和请求头 // 设置代理请求的目标 URL 和请求头
let header: any = {}; let header: any = {};
if (req.headers?.['Authorization'] && !req.headers?.['authorization']) { if (req.headers?.['Authorization'] && !req.headers?.['authorization']) {
@ -41,6 +44,9 @@ export const apiProxy = (req: http.IncomingMessage, res: http.ServerResponse, pr
// 处理大小写不一致的cookie // 处理大小写不一致的cookie
header.cookie = req.headers['cookie'] || req.headers['Cookie']; header.cookie = req.headers['cookie'] || req.headers['Cookie'];
} }
// console.log('host', req.headers.host, proxyApi, _u.href, req.headers.authorization);
const origin = req.headers?.origin || req.headers?.referer || 'http://localhost';
const cookieHost = new URL(origin).host.split(':')[0];
// 提取req的headers中的非HOST的header // 提取req的headers中的非HOST的header
const headers = Object.keys(req.headers).filter((item) => item && item.toLowerCase() !== 'host'); const headers = Object.keys(req.headers).filter((item) => item && item.toLowerCase() !== 'host');
headers.forEach((item) => { headers.forEach((item) => {
@ -54,11 +60,12 @@ export const apiProxy = (req: http.IncomingMessage, res: http.ServerResponse, pr
} }
header[item] = req.headers[item]; header[item] = req.headers[item];
}); });
const options: http.RequestOptions = { const options: http.RequestOptions | https.RequestOptions = {
host: _u.hostname, host: _u.hostname,
path: req.url, path: req.url,
method: req.method, method: req.method,
headers: { headers: {
['kevisual-origin']: 'assistant',
...header, ...header,
}, },
}; };
@ -67,12 +74,20 @@ export const apiProxy = (req: http.IncomingMessage, res: http.ServerResponse, pr
// @ts-ignore // @ts-ignore
options.port = _u.port; options.port = _u.port;
} }
const isHttps = _u.protocol === 'https:';
const httpProxy = _u.protocol === 'https:' ? https : http; const httpProxy = _u.protocol === 'https:' ? https : http;
console.log('httpProxy', { isHttps, target, url: _u.href });
if (isHttps) {
// @ts-ignore
options.rejectUnauthorized = false; // 忽略证书错误
}
// 创建代理请求 // 创建代理请求
const proxyReq = httpProxy.request(options, (proxyRes) => { const proxyReq = httpProxy.request(options, (proxyRes) => {
// Modify the 'set-cookie' headers using rewriteCookieDomain // Modify the 'set-cookie' headers using rewriteCookieDomain
if (proxyRes.headers['set-cookie']) { if (proxyRes.headers['set-cookie']) {
proxyRes.headers['set-cookie'] = proxyRes.headers['set-cookie'].map((cookie) => rewriteCookieDomain(cookie, 'localhost')); proxyRes.headers['set-cookie'] = proxyRes.headers['set-cookie'].map((cookie) => rewriteCookieDomain(cookie, cookieHost));
console.log('rewritten set-cookie:', proxyRes.headers['set-cookie']);
} }
// 将代理服务器的响应头和状态码返回给客户端 // 将代理服务器的响应头和状态码返回给客户端
res.writeHead(proxyRes.statusCode, proxyRes.headers); res.writeHead(proxyRes.statusCode, proxyRes.headers);

View File

@ -1,5 +1,5 @@
export * from './proxy.ts'; export * from './proxy.ts';
export * from './file-proxy.ts'; export * from './file-proxy.ts';
export { default as send } from 'send'; export { default as send } from 'send';
export * from './api-proxy.ts'; export * from './http-proxy.ts';
export * from './ws-proxy.ts'; export * from './ws-proxy.ts';

View File

@ -1,4 +1,7 @@
export type ProxyInfo = { export type ProxyInfo = {
/**
* , /root/center,
*/
path?: string; path?: string;
/** /**
* *
@ -7,21 +10,28 @@ export type ProxyInfo = {
/** /**
* *
*/ */
type?: 'static' | 'dynamic' | 'minio'; type?: 'file' | 'dynamic' | 'minio' | 'http';
/** /**
* 使websocket * pathname url.pathname, pathname使pathname作为请求的url.pathname
* @default undefined
* @example /api/v1/user
*/
pathname?: string;
/**
* 使websocket, http
* @default false * @default false
*/ */
ws?: boolean; ws?: boolean;
/** /**
* index.html 访 * index.html type为fileProxy代理有用 访
*/ */
indexPath?: string; indexPath?: string;
/** /**
* , process.cwd() * , process.cwd(), type为fileProxy代理有用
*/ */
rootPath?: string; rootPath?: string;
}; };
export type ApiList = { export type ApiList = {
path: string; path: string;
/** /**

View File

@ -1,49 +1,103 @@
import { Server } from 'http'; import type { Server as HttpsServer } from 'node:https';
import WebSocket from 'ws'; import type { Server as HttpServer } from 'node:http';
import type { Http2Server } from 'node:http2';
import { WebSocket } from 'ws';
import { ProxyInfo } from './proxy.ts'; import { ProxyInfo } from './proxy.ts';
import { WebSocketServer } from 'ws';
export const wss = new WebSocketServer({
noServer: true,
});
type WssAppOptions = {
wss: WebSocketServer;
apiList: ProxyInfo[];
};
class WssApp {
wss: WebSocketServer;
apiList: ProxyInfo[];
constructor(opts: WssAppOptions) {
this.wss = opts.wss;
this.apiList = opts.apiList;
}
upgrade(request: any, socket: any, head: any) {
const req = request as any;
const wss = this.wss;
const url = new URL(req.url, 'http://localhost');
const id = url.searchParams.get('id');
console.log('upgrade', request.url, id);
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
}
async handleConnection(ws: WebSocket, req: any) {
console.log('connected', req.url);
const that = this;
const proxyApiList: ProxyInfo[] = that.apiList || [];
const proxyApi = proxyApiList.find((item) => req.url.startsWith(item.path));
if (proxyApi && proxyApi.ws) {
const _u = new URL(req.url, `${proxyApi.target}`);
if (proxyApi.pathname) {
_u.pathname = _u.pathname.replace(proxyApi.path, proxyApi.pathname);
}
const isHttps = _u.protocol === 'https:';
const wsProtocol = isHttps ? 'wss' : 'ws';
const wsUrl = `${wsProtocol}://${_u.host}${_u.pathname}`;
console.log('WebSocket proxy URL', { wsUrl });
const proxySocket = new WebSocket(wsUrl, {
// headers: req.headers,
rejectUnauthorized: false,
});
proxySocket.on('open', () => {
ws.on('message', (message) => {
console.log('WebSocket client message', message);
proxySocket.send(message);
});
proxySocket.on('message', (message) => {
// console.log('WebSocket proxy message', message);
ws.send(message.toString());
});
});
proxySocket.on('error', (err) => {
console.error(`WebSocket proxy error: ${err.message}`);
});
proxySocket.on('close', () => {
console.log('WebSocket proxy closed');
});
ws.on('error', (err) => {
console.error('WebSocket client error', err);
proxySocket?.close?.();
});
ws.on('close', () => {
console.log('WebSocket client closed');
proxySocket?.close?.();
});
} else {
ws.send(JSON.stringify({ type: 'error', message: 'not found' }));
ws.close();
}
}
}
/** /**
* websocket代理 * websocket代理
* apiList: [{ path: '/api/router', target: 'https://kevisual.xiongxiao.me' }] * apiList: [{ path: '/api/router', target: 'https://kevisual.xiongxiao.me' }]
* @param server * @param server
* @param config * @param config
*/ */
export const wsProxy = (server: Server, config: { apiList: ProxyInfo[] }) => { export const wsProxy = (server: HttpServer | HttpsServer | Http2Server, config: { apiList: ProxyInfo[] }) => {
console.log('Upgrade initialization started'); // console.log('Upgrade initialization started');
const wssApp = new WssApp({
server.on('upgrade', (req, socket, head) => { wss: wss,
const proxyApiList: ProxyInfo[] = config?.apiList || []; apiList: config.apiList,
const proxyApi = proxyApiList.find((item) => req.url.startsWith(item.path)); });
wss.on('connection', async (ws, req) => {
if (proxyApi && proxyApi.ws) { // console.log('WebSocket connection established');
const _u = new URL(req.url, `${proxyApi.target}`); await wssApp.handleConnection(ws, req);
const isHttps = _u.protocol === 'https:'; });
const wsProtocol = isHttps ? 'wss' : 'ws'; // 处理升级请求
const wsUrl = `${wsProtocol}://${_u.hostname}${_u.pathname}`; server.on('upgrade', (request, socket, head) => {
wssApp.upgrade(request, socket, head);
const proxySocket = new WebSocket(wsUrl, {
headers: req.headers,
});
proxySocket.on('open', () => {
socket.on('data', (data) => {
proxySocket.send(data);
});
proxySocket.on('message', (message) => {
socket.write(message);
});
});
proxySocket.on('error', (err) => {
console.error(`WebSocket proxy error: ${err.message}`);
socket.end();
});
socket.on('error', () => {
proxySocket.close();
});
} else {
socket.end();
}
}); });
}; };

View File

@ -1,4 +1,5 @@
import { program, Command } from 'commander'; import { program, Command } from 'commander';
import { assistantConfig } from './config.ts';
import fs from 'fs'; import fs from 'fs';
// 将多个子命令加入主程序中 // 将多个子命令加入主程序中
let version = '0.0.1'; let version = '0.0.1';
@ -15,7 +16,7 @@ const ls = new Command('ls').description('List files in the current directory').
}); });
program.addCommand(ls); program.addCommand(ls);
export { program, Command }; export { program, Command, assistantConfig };
/** /**
* *

View File

@ -1,8 +1,10 @@
import { app } from './app.ts'; import { app } from './app.ts';
import { proxyRoute } from './services/proxy/proxy-page-index.ts'; import { proxyRoute, proxyWs } from './services/proxy/proxy-page-index.ts';
app.listen(51015, () => { app.listen(51015, () => {
console.log('Server is running on http://localhost:51015'); console.log('Server is running on http://localhost:51015');
}); });
app.server.on(proxyRoute); app.server.on(proxyRoute);
proxyWs();

View File

@ -1,3 +1,4 @@
import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { checkFileExists, AssistantConfig } from '@/module/assistant/index.ts'; import { checkFileExists, AssistantConfig } from '@/module/assistant/index.ts';
import { chalk } from '@/module/chalk.ts'; import { chalk } from '@/module/chalk.ts';
@ -40,5 +41,10 @@ export class AssistantInit extends AssistantConfig {
}); });
console.log(chalk.green('助手配置文件创建成功')); console.log(chalk.green('助手配置文件创建成功'));
} }
const env = this.configPath?.envConfigPath;
if (!checkFileExists(env, true)) {
fs.writeFileSync(env, '# 环境配置文件\n');
console.log(chalk.green('助手环境配置文件创建成功'));
}
} }
} }

View File

@ -43,8 +43,7 @@ export class LocalProxy {
} }
} }
init() { init() {
const frontAppDir = this.assistantConfig.configPath?.appDir; const frontAppDir = this.assistantConfig.configPath?.pageDir;
console.log('frontAppDir', frontAppDir);
if (frontAppDir) { if (frontAppDir) {
const userList = fs.readdirSync(frontAppDir); const userList = fs.readdirSync(frontAppDir);
const localProxyProxyList: ProxyType[] = []; const localProxyProxyList: ProxyType[] = [];

View File

@ -1,14 +1,14 @@
import { fileProxy, apiProxy, createApiProxy } from '@/module/assistant/index.ts'; import { fileProxy, httpProxy, createApiProxy, wsProxy } from '@/module/assistant/index.ts';
import http from 'http'; import http from 'http';
import { LocalProxy } from './local-proxy.ts'; import { LocalProxy } from './local-proxy.ts';
import { assistantConfig } from '@/app.ts'; import { assistantConfig, app } from '@/app.ts';
import { log } from '@/module/logger.ts'; import { log } from '@/module/logger.ts';
const localProxy = new LocalProxy({ const localProxy = new LocalProxy({
assistantConfig, assistantConfig,
}); });
export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResponse) => { export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResponse) => {
const _assistantConfig = assistantConfig.getCacheAssistantConfig(); const _assistantConfig = assistantConfig.getCacheAssistantConfig();
const appDir = assistantConfig.configPath?.appDir; const appDir = assistantConfig.configPath?.pageDir;
const url = new URL(req.url, 'http://localhost'); const url = new URL(req.url, 'http://localhost');
const pathname = url.pathname; const pathname = url.pathname;
if (pathname.startsWith('/favicon.ico')) { if (pathname.startsWith('/favicon.ico')) {
@ -17,7 +17,7 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
return; return;
} }
if (pathname.startsWith('/client')) { if (pathname.startsWith('/client')) {
console.log('handle by router'); console.debug('handle by router');
return; return;
} }
// client, api, v1, serve 开头的拦截 // client, api, v1, serve 开头的拦截
@ -25,8 +25,8 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
const defaultApiProxy = createApiProxy(_assistantConfig?.pageApi || 'https://kevisual.cn'); const defaultApiProxy = createApiProxy(_assistantConfig?.pageApi || 'https://kevisual.cn');
const apiBackendProxy = [...apiProxyList, ...defaultApiProxy].find((item) => pathname.startsWith(item.path)); const apiBackendProxy = [...apiProxyList, ...defaultApiProxy].find((item) => pathname.startsWith(item.path));
if (apiBackendProxy) { if (apiBackendProxy) {
console.log('apiBackendProxy', apiBackendProxy, req.url); log.debug('apiBackendProxy', { apiBackendProxy, url: req.url });
return apiProxy(req, res, { return httpProxy(req, res, {
path: apiBackendProxy.path, path: apiBackendProxy.path,
target: apiBackendProxy.target, target: apiBackendProxy.target,
}); });
@ -45,13 +45,19 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
} }
const proxyApiList = _assistantConfig?.proxy || []; const proxyApiList = _assistantConfig?.proxy || [];
const proxyApi = proxyApiList.find((item) => pathname.startsWith(item.path)); const proxyApi = proxyApiList.find((item) => pathname.startsWith(item.path));
if (proxyApi) { if (proxyApi && proxyApi.type === 'file') {
log.log('proxyApi', { proxyApi, pathname }); log.debug('proxyApi', { proxyApi, pathname });
const { user, key } = proxyApi; const _indexPath = proxyApi.indexPath || `${_user}/${_app}/index.html`;
const _rootPath = proxyApi.rootPath;
if (!_rootPath) {
log.error('Not Found rootPath', { proxyApi, pathname });
return res.end(`Not Found [${proxyApi.path}] rootPath`);
}
return fileProxy(req, res, { return fileProxy(req, res, {
path: proxyApi.path, // 代理路径, 比如/root/center path: proxyApi.path, // 代理路径, 比如/root/center
rootPath: appDir, // 根路径 rootPath: proxyApi.rootPath,
indexPath: `${user}/${key}/index.html`, // 首页路径 ...proxyApi,
indexPath: _indexPath, // 首页路径
}); });
} }
const localProxyProxyList = localProxy.getLocalProxyList(); const localProxyProxyList = localProxy.getLocalProxyList();
@ -60,26 +66,31 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
log.log('localProxyProxy', { localProxyProxy, url: req.url }); log.log('localProxyProxy', { localProxyProxy, url: req.url });
return fileProxy(req, res, { return fileProxy(req, res, {
path: localProxyProxy.path, path: localProxyProxy.path,
rootPath: assistantConfig.configPath?.appDir, rootPath: appDir,
indexPath: localProxyProxy.indexPath, indexPath: localProxyProxy.indexPath,
}); });
} }
console.log('handle by router 404', req.url);
const creatCenterProxy = createApiProxy(_assistantConfig?.pageApi || 'https://kevisual.cn', ['/root']); const creatCenterProxy = createApiProxy(_assistantConfig?.pageApi || 'https://kevisual.cn', ['/root']);
const centerProxy = creatCenterProxy.find((item) => pathname.startsWith(item.path)); const centerProxy = creatCenterProxy.find((item) => pathname.startsWith(item.path));
if (centerProxy) { if (centerProxy) {
console.log('centerProxy', centerProxy, req.url); return httpProxy(req, res, {
return apiProxy(req, res, {
path: centerProxy.path, path: centerProxy.path,
target: centerProxy.target, target: centerProxy.target,
type: 'static', type: 'http',
}); });
} }
log.debug('handle by router 404', req.url);
res.statusCode = 404; res.statusCode = 404;
res.end('Not Found Proxy'); res.end('Not Found Proxy');
// console.log('getCacheAssistantConfig().pageApi', getCacheAssistantConfig().pageApi); };
// return apiProxy(req, res, {
// path: url.pathname, export const proxyWs = () => {
// target: getCacheAssistantConfig().pageApi, const apiProxyList = assistantConfig.getCacheAssistantConfig()?.apiProxyList || [];
// }); const proxy = assistantConfig.getCacheAssistantConfig()?.proxy || [];
const proxyApi = [...apiProxyList, ...proxy].filter((item) => item.ws);
log.debug('proxyApi ', proxyApi);
wsProxy(app.server.server, {
apiList: proxyApi,
});
}; };

27
assistant/src/test/ws.ts Normal file
View File

@ -0,0 +1,27 @@
import { WebSocket } from 'ws';
export const main = () => {
// https://192.168.31.39:16000/tts
const url = 'https://192.168.31.39:16000/tts';
// const wss
const ws = new WebSocket('wss://192.168.31.39:16000/tts', {
rejectUnauthorized: false, // 禁用证书验证
});
ws.on('open', () => {
console.log('WebSocket connection opened');
});
ws.on('message', (data) => {
console.log(`Received message: ${data}`);
});
ws.on('close', () => {
console.log('WebSocket test connection closed');
});
ws.on('error', (error) => {
console.error(`WebSocket error: ${error}`);
});
};
main();

View File

@ -0,0 +1,14 @@
const task1 = {
description: '下载前端应用 root/center 应用',
command: 'ev app download -i root/center -o assistant-app/page',
};
const task2 = {
description: '下载前端应用 root/talkshow-admin 应用',
command: 'ev app download -i root/talkshow-admin -o assistant-app/page',
};
const task3 = {
description: '安装后端应用 root/talkshow-code-center 应用',
command: 'ev app download -i root/talkshow-code-center -t app -o assistant-app/apps/talkshow-code-center',
};

View File

@ -0,0 +1,4 @@
#!/usr/bin/env node
import { runParser } from '../dist/assistant.mjs';
runParser(process.argv);

1224
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff