This commit is contained in:
熊潇 2025-04-25 02:14:24 +08:00
parent 6827945446
commit 9eb4d06939
38 changed files with 1941 additions and 42 deletions

8
assistant/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules
.DS_Store
dist
pack-dist
assistant-app

19
assistant/bun.config.mjs Normal file
View File

@ -0,0 +1,19 @@
// @ts-check
// https://bun.sh/docs/bundler
// @ts-ignore
import pkg from './package.json';
// bun run src/index.ts --
await Bun.build({
target: 'node',
format: 'esm',
entrypoints: ['./src/index.ts'],
outdir: './dist',
naming: {
entry: 'assistant.mjs',
},
define: {
ENVISION_VERSION: JSON.stringify(pkg.version),
},
env: 'ENVISION_*',
});

48
assistant/package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "@kevisual/assistant-cli",
"version": "0.0.1",
"description": "",
"main": "dist/assistant.mjs",
"keywords": [
"kevisual",
"cli",
"assistant"
],
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT",
"packageManager": "pnpm@10.7.0",
"type": "module",
"files": [
"dist",
"bin",
"bun.config.mjs"
],
"scripts": {
"dev": "bun run src/run.ts ",
"dev:serve": "bun --watch src/serve.ts",
"build": "rimraf dist && bun run bun.config.mjs"
},
"devDependencies": {
"@kevisual/load": "^0.0.6",
"@kevisual/query": "0.0.17",
"@kevisual/query-login": "0.0.5",
"@kevisual/router": "^0.0.13",
"@kevisual/use-config": "^1.0.11",
"@types/bun": "^1.2.10",
"@types/node": "^22.14.1",
"@types/send": "^0.17.4",
"chalk": "^5.4.1",
"commander": "^13.1.0",
"inquirer": "^12.5.2",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"send": "^1.2.0",
"zustand": "^5.0.3"
},
"engines": {
"node": ">=22.0.0"
},
"publishConfig": {
"access": "public"
}
}

View File

@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICXTCCAcagAwIBAgIJUmN5oWFZdxK8MA0GCSqGSIb3DQEBBQUAMF8xCjAIBgNV
BAMTASoxCzAJBgNVBAYTAkNOMREwDwYDVQQIEwhaaGVKaWFuZzERMA8GA1UEBxMI
SGFuZ3pob3UxETAPBgNVBAoTCEVudmlzaW9uMQswCQYDVQQLEwJJVDAeFw0yNTA0
MjQxODExMjZaFw0yNjA0MjQxODExMjZaMF8xCjAIBgNVBAMTASoxCzAJBgNVBAYT
AkNOMREwDwYDVQQIEwhaaGVKaWFuZzERMA8GA1UEBxMISGFuZ3pob3UxETAPBgNV
BAoTCEVudmlzaW9uMQswCQYDVQQLEwJJVDCBnzANBgkqhkiG9w0BAQEFAAOBjQAw
gYkCgYEAirpqS9Lwh5JNY7N303wphXCR/HZDgfw1HnP6b62WVJTHtU97hLKjrTXx
zUYPEyySXLzFGjptKSjT3ZgulV1I9YBXg2gdDibxxxZUZHoJ8j0oh+MSxRv1fTzw
+HEBErUJQJ4lHnf9nbi7Tf48XiNWqh9Lce3XvyDFQoRDASX5yeUCAwEAAaMhMB8w
HQYDVR0RBBYwFIIBKoIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBBQUAA4GB
AGCYapPzhY0zUVZxo6CsijdDQpuHe2G3cDs4bzpF2YHRGN3t8/cPwROt7FWCkzBt
b7g/Tar+200fGspmLS95QisjiKo0fAKfaEE8CHXr2jlt8+omOz0tPg9LCZi2GtgI
8EC+Vvvcd9UjzHmoPBZQF4qAvJ2IyOwBh6Vwyh8las+e
-----END CERTIFICATE-----

View File

@ -0,0 +1,15 @@
-----BEGIN RSA PRIVATE KEY-----
MIICWwIBAAKBgQCKumpL0vCHkk1js3fTfCmFcJH8dkOB/DUec/pvrZZUlMe1T3uE
sqOtNfHNRg8TLJJcvMUaOm0pKNPdmC6VXUj1gFeDaB0OJvHHFlRkegnyPSiH4xLF
G/V9PPD4cQEStQlAniUed/2duLtN/jxeI1aqH0tx7de/IMVChEMBJfnJ5QIDAQAB
AoGAGmBUKoN6OQSPk0fBniOqz1S2ZP5lWncF8HrToF0sSnuZNvdcQEAoz5uElGdg
IWClmV1IynJWY+9/zM+M99grMT6it3VHHVM3MQoTf1Am4Vy0qgKR6Y1TzE0XLrVW
3e3ezDph3gG0EQsRxVbn/goCEfstuhJaFyxHvsQRtPY+Z1ECQQC8ffbjV8hb911o
iUw67FquOL9AYrFfQfohkQZ1TrDv0VTCAYpB7e5ml4dBhUL1dQbFV7avZIufD5fl
pxCKkAPNAkEAvGnQPByrKj6ggf2l1CzgjXzZ24wm3AkJutWkFjAcf5EFy0+MIBOi
ejyrGcGi9eovXCLGLrgzaBeAHa4XNkX2eQJAEZE73VxlFA0t63xAWo2EthAb4whP
t60SfuZhT7WR0AgWei5ikFp4iZ89v+GHqBDMHMBcCmS4jo6JfaHgbMmXUQJAAIDL
1I1DC77VEOPLgJCKHPabYlGyfN3tT7loUcLZIKITgOJ6fk9vHKJy1oPE2qFAdR+G
pfNJ99owNmQTncp8CQJAI3fp5VABViB3uha4cHmpRUvoGNWmmh9Ob6LypDsGtd8z
8ah+4Ek1DvsQC4XDuwgwnQsCmEYfa2P1T/GIdqPadw==
-----END RSA PRIVATE KEY-----

7
assistant/readme.md Normal file
View File

@ -0,0 +1,7 @@
# assistant cli
## 初始化路径
## 启动服务
## 配置

19
assistant/src/app.ts Normal file
View File

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

View File

@ -0,0 +1,24 @@
import { program, Command } from '@/program.ts';
import { AssistantInit } from '@/services/init/index.ts';
import path from 'node:path';
type InitCommandOptions = {
path?: string;
};
const Init = new Command('init')
.description('初始化一个助手客户端,生成配置文件。')
.option('-p --path <path>', '助手路径,默认为执行命令的目录,如果助手路径不存在则创建。')
.action((opts: InitCommandOptions) => {
// 如果path参数存在检测path是否是相对路径如果是相对路径则转换为绝对路径
if (opts.path && !opts.path.startsWith('/')) {
opts.path = path.join(process.cwd(), opts.path);
} else if (opts.path) {
opts.path = path.resolve(opts.path);
}
const assistantInit = new AssistantInit({
path: opts.path,
});
assistantInit.init();
});
program.addCommand(Init);

20
assistant/src/index.ts Normal file
View File

@ -0,0 +1,20 @@
import { program, runProgram } from '@/program.ts';
import './command/init/index.ts';
/**
*
* args[0] , example: node
* args[1] , example: index.ts
* @param argv
*/
export const runParser = async (argv: string[]) => {
// program.parse(process.argv);
// console.log('argv', argv);
try {
program.parse(argv);
} catch (error) {
console.error('执行错误:', error.message);
}
};
export { runProgram };

View File

@ -0,0 +1,175 @@
import path from 'path';
import { homedir } from 'os';
import fs from 'fs';
import { checkFileExists, createDir } from '../file/index.ts';
import { ProxyInfo } from '../proxy/proxy.ts';
/**
* ,
*/
const configDir = createDir(path.join(homedir(), '.config/envision/assistant-app'));
/**
*
* @param configRootPath
* @returns
*/
export const initConfig = (configRootPath: string) => {
const configDir = createDir(path.join(configRootPath, 'assistant-app'));
const configPath = path.join(configDir, 'assistant-config.json');
const appConfigPath = path.join(configDir, 'assistant-app-config.json');
const appDir = createDir(path.join(configDir, 'frontend'));
const serviceDir = createDir(path.join(configDir, 'services'));
const serviceConfigPath = path.join(serviceDir, 'assistant-service-config.json');
const appPidPath = path.join(configDir, 'assistant-app.pid');
return {
/**
*
*/
configDir,
/**
* , assistant-config.json
*/
configPath,
/**
*
*/
serviceDir,
/**
* assistant-service-config.json
*/
serviceConfigPath,
/**
*
*/
appDir,
/**
* , assistant-app-config.json
*/
appConfigPath,
/**
* pid文件路径
*/
appPidPath,
};
};
export type ReturnInitConfigType = ReturnType<typeof initConfig>;
type AssistantConfigData = {
pageApi?: string; // https://kevisual.silkyai.cn
proxy?: { user: string; key: string; path: string }[];
apiProxyList?: ProxyInfo[];
description?: string;
};
let assistantConfig: AssistantConfigData;
type AssistantConfigOptions = {
configDir?: string;
init?: boolean;
};
export class AssistantConfig {
config: AssistantConfigData;
configPath: ReturnInitConfigType;
configDir: string;
constructor(opts?: AssistantConfigOptions) {
this.configDir = opts?.configDir || configDir;
if (opts?.init) {
this.init();
}
}
init() {
this.configPath = initConfig(this.configDir);
}
getConfig() {
try {
if (!checkFileExists(this.configPath.configPath)) {
fs.writeFileSync(this.configPath.configPath, JSON.stringify({ proxy: [] }, null, 2));
return {
pageApi: '',
proxy: [],
};
}
assistantConfig = JSON.parse(fs.readFileSync(this.configPath.configPath, 'utf8'));
return assistantConfig;
} catch (error) {
console.error('file read', error.message);
return {
pageApi: '',
proxy: [],
};
}
}
getCacheAssistantConfig() {
if (this.config) {
return this.config;
}
return this.getConfig();
}
/**
* assistant-config.json
* @param config
* @returns
*/
setConfig(config?: AssistantConfigData) {
const myConfig = this.getCacheAssistantConfig();
const newConfig = { ...myConfig, ...config };
this.config = newConfig;
fs.writeFileSync(this.configPath.configPath, JSON.stringify(newConfig, null, 2));
return newConfig;
}
/**
*
* @returns
*/
getAppConfig(): AppConfig {
const { appConfigPath } = this.configPath;
if (!checkFileExists(appConfigPath)) {
return {
list: [],
};
}
return JSON.parse(fs.readFileSync(appConfigPath, 'utf8'));
}
setAppConfig(config?: AppConfig) {
const _config = this.getAppConfig();
const _saveConfig = { ..._config, ...config };
const { appConfigPath } = this.configPath;
fs.writeFileSync(appConfigPath, JSON.stringify(_saveConfig, null, 2));
return _saveConfig;
}
assAppConfig(app: any) {
const config = this.getAppConfig();
const assistantConfig = this.getCacheAssistantConfig();
const _apps = config.list;
const _proxy = assistantConfig.proxy || [];
const { user, key } = app;
const newProxyInfo = {
user,
key,
path: `/${user}/${key}`,
};
const _proxyIndex = _proxy.findIndex((_proxy: any) => _proxy.path === newProxyInfo.path);
if (_proxyIndex !== -1) {
_proxy[_proxyIndex] = newProxyInfo;
} else {
_proxy.push(newProxyInfo);
}
const _app = _apps.findIndex((_app: any) => _app.id === app.id);
if (_app !== -1) {
_apps[_app] = app;
} else {
_apps.push(app);
}
this.setAppConfig({ ...config, list: _apps });
this.setConfig({ ...assistantConfig, proxy: _proxy });
return config;
}
getAppList() {
return this.getAppConfig().list;
}
}
type AppConfig = {
list: any[];
};

View File

@ -0,0 +1,26 @@
import fs from 'node:fs';
/**
*
* @param filePath
* @param checkIsFile default: false
* @returns
*/
export const checkFileExists = (filePath: string, checkIsFile = false) => {
try {
fs.accessSync(filePath);
if (checkIsFile) {
return fs.statSync(filePath).isFile();
}
return true;
} catch (error) {
return false;
}
};
export const createDir = (dirPath: string) => {
if (!checkFileExists(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
return dirPath;
};

View File

@ -0,0 +1,27 @@
export function rewriteCookieDomain(cookie: string, domainRewrite: string | Record<string, string>) {
if (!domainRewrite) return cookie;
// 解析 Cookie 的属性
const parts = cookie.split(';').map((part) => part.trim());
const nameValue = parts[0];
const attributes = parts.slice(1);
// 查找并替换 Domain 属性
const newAttributes = attributes.map((attr) => {
if (attr.startsWith('Domain=')) {
const originalDomain = attr.slice(7); // 去掉 "Domain="
let newDomain = domainRewrite;
// 如果 domainRewrite 是对象,根据映射关系替换
if (typeof domainRewrite === 'object') {
newDomain = domainRewrite[originalDomain] || originalDomain;
}
return `Domain=${newDomain}`;
}
return attr;
});
// 重新组合 Cookie
return [nameValue, ...newAttributes].join('; ');
}

View File

@ -0,0 +1,79 @@
import { createCert } from '@kevisual/router/sign';
import path from 'node:path';
import fs from 'node:fs';
import { AssistantConfig } from '../config/index.ts';
import { checkFileExists } from '../file/index.ts';
import { chalk } from '@/module/chalk.ts';
type Attributes = {
name: string;
value: string;
};
type AltNames = {
type: number;
value?: string;
ip?: string;
};
export class HttpsPem {
assistantConfig: AssistantConfig;
key: string;
cert: string;
constructor(assistantConfig: AssistantConfig) {
this.assistantConfig = assistantConfig;
const { key, cert } = this.getCert();
this.key = key;
this.cert = cert;
}
getPemDir() {
const configDir = this.assistantConfig.configPath?.configDir || process.cwd();
const pemDir = path.join(configDir, 'pem');
if (!checkFileExists(pemDir)) {
fs.mkdirSync(pemDir, { recursive: true });
}
return pemDir;
}
getCert() {
const pemDir = this.getPemDir();
const pemPath = {
key: path.join(pemDir, 'https-private-key.pem'),
cert: path.join(pemDir, 'https-cert.pem'),
};
if (!checkFileExists(pemPath.key) || !checkFileExists(pemPath.cert)) {
const { key, cert } = this.createCert();
fs.writeFileSync(pemPath.key, key);
fs.writeFileSync(pemPath.cert, cert);
console.log(chalk.green('证书创建成功'))
return {
key,
cert,
};
}
const key = fs.readFileSync(pemPath.key, 'utf-8');
const cert = fs.readFileSync(pemPath.cert, 'utf-8');
return {
key,
cert,
};
}
createCert(attrs?: Attributes[], altNames?: AltNames[]) {
const attributes = attrs || [];
const altNamesList = altNames || [];
const { key, cert } = createCert(
[
{
name: 'commonName',
value: 'localhost',
},
{
name: 'organizationName',
value: 'kevisual',
},
...attributes,
],
altNamesList,
);
return {
key,
cert,
};
}
}

View File

@ -0,0 +1,7 @@
export * from './install/index.ts';
export * from './config/index.ts';
export * from './file/index.ts';
export * from './process/index.ts';
export * from './proxy/index.ts';

View File

@ -0,0 +1,121 @@
import path from 'node:path';
import fs from 'node:fs';
import { checkFileExists } from '../file/index.ts';
type DownloadTask = {
downloadPath: string;
downloadUrl: string;
user: string;
key: string;
version: string;
};
export type Package = {
id: string;
name?: string;
version?: string;
description?: string;
title?: string;
user?: string;
key?: string;
[key: string]: any;
};
type InstallAppOpts = {
appDir?: string;
kevisualUrl?: string;
/**
* , assistant-config的下面
*/
};
export const installApp = async (app: Package, opts: InstallAppOpts = {}) => {
// const _app = demoData;
const { appDir = '', kevisualUrl = 'https://kevisual.cn' } = opts;
const _app = app;
try {
let files = _app.data.files || [];
const version = _app.version;
const user = _app.user;
const key = _app.key;
const downFiles = files.map((file: any) => {
const noVersionPath = file.path.replace(`/${version}`, '');
return {
...file,
downloadPath: path.join(appDir, noVersionPath),
downloadUrl: `${kevisualUrl}/${noVersionPath}`,
};
});
const downloadTasks: DownloadTask[] = downFiles as any;
for (const file of downloadTasks) {
const downloadPath = file.downloadPath;
const downloadUrl = file.downloadUrl;
const dir = path.dirname(downloadPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const res = await fetch(downloadUrl);
const blob = await res.blob();
fs.writeFileSync(downloadPath, Buffer.from(await blob.arrayBuffer()));
}
let indexHtml = files.find((file: any) => file.name === 'index.html');
if (!indexHtml) {
files.push({
name: 'index.html',
path: `${user}/${key}/index.html`,
});
fs.writeFileSync(path.join(appDir, `${user}/${key}/index.html`), JSON.stringify(app, null, 2));
}
_app.data.files = files;
return {
code: 200,
data: _app,
message: 'Install app success',
};
} catch (error) {
console.error(error);
return {
code: 500,
message: 'Install app failed',
};
}
};
export const checkAppDir = (appDir: string) => {
const files = fs.readdirSync(appDir);
if (files.length === 0) {
fs.rmSync(appDir, { recursive: true });
}
};
type UninstallAppOpts = {
appDir?: string;
};
export const uninstallApp = async (app: Partial<Package>, opts: UninstallAppOpts = {}) => {
const { appDir = '' } = opts;
try {
const { user, key } = app;
const keyDir = path.join(appDir, user, key);
const parentDir = path.join(appDir, user);
if (!checkFileExists(appDir) || !checkFileExists(keyDir)) {
return {
code: 200,
message: 'uninstall app success',
};
}
try {
// 删除appDir和文件
fs.rmSync(keyDir, { recursive: true });
} catch (error) {
console.error(error);
}
checkAppDir(parentDir);
return {
code: 200,
message: 'Uninstall app success',
};
} catch (error) {
console.error(error);
return {
code: 500,
message: 'Uninstall app failed',
};
}
};

View File

@ -0,0 +1,82 @@
import { ChildProcess, fork, ForkOptions } from 'child_process';
class BaseProcess {
private process: ChildProcess;
status: 'running' | 'stopped' | 'error' = 'stopped';
appPath: string;
args: any[] = [];
opts: ForkOptions = {};
/*
* , 0 TODO,
*/
restartCount: number = 0;
constructor(appPath?: string, args?: any[], opts?: ForkOptions) {
this.appPath = appPath;
this.args = args || [];
this.opts = opts || {};
// this.createProcess(appPath);
}
createProcess(appPath: string = this.appPath, args: any[] = [], opts: ForkOptions = {}) {
if (this.process) {
this.process.kill();
}
this.appPath = appPath || this.appPath;
this.args = args || this.args;
this.opts = {
...this.opts,
...opts,
};
this.process = fork(appPath, args, {
stdio: 'inherit',
...this.opts,
env: {
...process.env,
NODE_ENV_PARENT: 'fork',
...this.opts?.env,
},
});
return this;
}
kill(signal?: NodeJS.Signals | number) {
if (this.process) {
this.process.kill(signal);
}
return this;
}
public send(message: any) {
this.process.send(message);
}
public on(event: string, callback: (message: any) => void) {
this.process.on(event, callback);
}
public onExit(callback: (code: number) => void) {
this.process.on('exit', callback);
}
public onError(callback: (error: Error) => void) {
this.process.on('error', callback);
}
public onMessage(callback: (message: any) => void) {
this.process.on('message', callback);
}
public onClose(callback: () => void) {
this.process.on('close', callback);
}
public onDisconnect(callback: () => void) {
this.process.on('disconnect', callback);
}
restart() {
this.kill();
this.createProcess();
}
}
export class AssistantProcess extends BaseProcess {
constructor(appPath: string) {
super(appPath);
}
}

View File

@ -0,0 +1,91 @@
import http from 'node:http';
import https from 'node:https';
import { rewriteCookieDomain } from '../https/cookie-rewrite.ts';
import { ProxyInfo } from './proxy.ts';
export const defaultApiProxy = [
{
path: '/api/router',
target: 'https://kevisual.cn',
},
{
path: '/v1',
target: 'https://kevisual.cn',
},
];
/**
* api代理
* @param api
* @param paths ['/api/router', '/v1' ]
* @returns
*/
export const createApiProxy = (api: string, paths: string[] = ['/api', '/v1']) => {
const pathList = paths.map((item) => {
return {
path: item,
target: new URL(api).origin,
};
});
return pathList;
};
export const apiProxy = (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
const { target } = proxyApi;
const _u = new URL(req.url, `${target}`);
console.log('proxyApi', { url: req.url, target: _u.href });
// 设置代理请求的目标 URL 和请求头
let header: any = {};
if (req.headers?.['Authorization'] && !req.headers?.['authorization']) {
header.authorization = req.headers['Authorization'];
}
if (req.headers?.['cookie'] || req.headers?.['Cookie']) {
// 处理大小写不一致的cookie
header.cookie = req.headers['cookie'] || req.headers['Cookie'];
}
// 提取req的headers中的非HOST的header
const headers = Object.keys(req.headers).filter((item) => item && item.toLowerCase() !== 'host');
headers.forEach((item) => {
if (item.toLowerCase() === 'origin') {
header.origin = new URL(target).origin;
return;
}
if (item.toLowerCase() === 'referer') {
header.referer = new URL(req.url, target).href;
return;
}
header[item] = req.headers[item];
});
const options: http.RequestOptions = {
host: _u.hostname,
path: req.url,
method: req.method,
headers: {
...header,
},
};
// console.log('options', JSON.stringify(options, null, 2));
if (_u.port) {
// @ts-ignore
options.port = _u.port;
}
const httpProxy = _u.protocol === 'https:' ? https : http;
// 创建代理请求
const proxyReq = httpProxy.request(options, (proxyRes) => {
// Modify the 'set-cookie' headers using rewriteCookieDomain
if (proxyRes.headers['set-cookie']) {
proxyRes.headers['set-cookie'] = proxyRes.headers['set-cookie'].map((cookie) => rewriteCookieDomain(cookie, 'localhost'));
}
// 将代理服务器的响应头和状态码返回给客户端
res.writeHead(proxyRes.statusCode, proxyRes.headers);
// 将代理响应流写入客户端响应
proxyRes.pipe(res, { end: true });
});
// 处理代理请求的错误事件
proxyReq.on('error', (err) => {
console.error(`Proxy request error: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.write(`Proxy request error: ${err.message}`);
});
// 处理 POST 请求的请求体(传递数据到目标服务器),end:true 表示当请求体结束时,关闭请求
req.pipe(proxyReq, { end: true });
return;
};

View File

@ -0,0 +1,49 @@
import http from 'node:http';
import send from 'send';
import fs from 'node:fs';
import path from 'path';
import { ProxyInfo } from './proxy.ts';
import { checkFileExists } from '../file/index.ts';
export const fileProxy = (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
// url开头的文件
const url = new URL(req.url, 'http://localhost');
const [user, key, _info] = url.pathname.split('/');
const pathname = url.pathname.slice(1);
const { indexPath = '', target = '', rootPath = process.cwd() } = proxyApi;
try {
// 检测文件是否存在如果文件不存在则返回404
let filePath = '';
let exist = false;
if (_info) {
filePath = path.join(rootPath, target, pathname);
exist = checkFileExists(filePath, true);
}
if (!exist) {
filePath = path.join(rootPath, target, indexPath);
exist = checkFileExists(filePath, true);
}
console.log('filePath', filePath, exist);
if (!exist) {
res.statusCode = 404;
res.end('Not Found File');
return;
}
const ext = path.extname(filePath);
let maxAge = 24 * 60 * 60 * 1000; // 24小时
if (ext === '.html') {
maxAge = 0;
}
let sendFilePath = path.relative(rootPath, filePath);
const file = send(req, sendFilePath, {
root: rootPath,
maxAge,
});
file.pipe(res);
} catch (error) {
res.statusCode = 404;
res.end('Error:Not Found File');
return;
}
};

View File

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

View File

@ -0,0 +1,50 @@
export type ProxyInfo = {
path?: string;
/**
*
*/
target?: string;
/**
*
*/
type?: 'static' | 'dynamic' | 'minio';
/**
* 使websocket
* @default false
*/
ws?: boolean;
/**
* index.html 访
*/
indexPath?: string;
/**
* , process.cwd()
*/
rootPath?: string;
};
export type ApiList = {
path: string;
/**
* url或者相对路径
*/
target: string;
/**
*
*/
ws?: boolean;
/**
*
*/
type?: 'static' | 'dynamic' | 'minio';
}[];
/**
[
{
path: '/api/v1/user',
target: 'http://localhost:3000/api/v1/user',
type: 'dynamic',
},
]
*/

View File

@ -0,0 +1,49 @@
import { Server } from 'http';
import WebSocket from 'ws';
import { ProxyInfo } from './proxy.ts';
/**
* websocket代理
* apiList: [{ path: '/api/router', target: 'https://kevisual.xiongxiao.me' }]
* @param server
* @param config
*/
export const wsProxy = (server: Server, config: { apiList: ProxyInfo[] }) => {
console.log('Upgrade initialization started');
server.on('upgrade', (req, socket, head) => {
const proxyApiList: ProxyInfo[] = config?.apiList || [];
const proxyApi = proxyApiList.find((item) => req.url.startsWith(item.path));
if (proxyApi && proxyApi.ws) {
const _u = new URL(req.url, `${proxyApi.target}`);
const isHttps = _u.protocol === 'https:';
const wsProtocol = isHttps ? 'wss' : 'ws';
const wsUrl = `${wsProtocol}://${_u.hostname}${_u.pathname}`;
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

@ -0,0 +1,2 @@
import { Chalk } from 'chalk';
export const chalk = new Chalk({ level: 3 });

View File

@ -0,0 +1,42 @@
import { pino } from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
},
serializers: {
error: pino.stdSerializers.err,
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
},
base: {
app: 'assistant',
env: process.env.NODE_ENV || 'production',
},
});
export const console = {
log: logger.info,
error: logger.error,
warn: logger.warn,
info: logger.info,
debug: logger.debug,
};
export const logError = (message: string, data?: any) => logger.error({ data }, message);
export const logWarning = (message: string, data?: any) => logger.warn({ data }, message);
export const logInfo = (message: string, data?: any) => logger.info({ data }, message);
export const logDebug = (message: string, data?: any) => logger.debug({ data }, message);
export const log = {
log: logInfo,
error: logError,
warn: logWarning,
info: logInfo,
debug: logDebug,
};

28
assistant/src/program.ts Normal file
View File

@ -0,0 +1,28 @@
import { program, Command } from 'commander';
import fs from 'fs';
// 将多个子命令加入主程序中
let version = '0.0.1';
try {
// @ts-ignore
if (ENVISION_VERSION) version = ENVISION_VERSION;
} catch (e) {}
// @ts-ignore
program.name('app').description('A CLI tool with envison').version(version);
const ls = new Command('ls').description('List files in the current directory').action(() => {
console.log('List files');
console.log(fs.readdirSync(process.cwd()));
});
program.addCommand(ls);
export { program, Command };
/**
*
*
* @param args
*/
export const runProgram = (args: string[]) => {
const [_app, _command] = process.argv;
program.parse([_app, _command, ...args]);
};

6
assistant/src/run.ts Normal file
View File

@ -0,0 +1,6 @@
import { runParser } from './index.ts';
/**
* test run parser
*/
runParser(process.argv);

8
assistant/src/serve.ts Normal file
View File

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

View File

@ -0,0 +1,44 @@
import path from 'node:path';
import { checkFileExists, AssistantConfig } from '@/module/assistant/index.ts';
import { chalk } from '@/module/chalk.ts';
export type AssistantInitOptions = {
path?: string;
};
/**
*
* @class AssistantInit
*/
export class AssistantInit extends AssistantConfig {
constructor(opts?: AssistantInitOptions) {
const configDir = opts?.path || process.cwd();
super({
configDir,
});
}
async init() {
// 1. 检查助手路径是否存在
if (!this.checkConfigPath()) {
console.log(chalk.blue('助手路径不存在,正在创建...'));
super.init();
this.createAssistantConfig();
} else {
super.init();
console.log(chalk.yellow('助手路径已存在'));
return;
}
}
checkConfigPath() {
const assistantPath = path.join(this.configDir, 'assistant-config.json');
return checkFileExists(assistantPath);
}
createAssistantConfig() {
const assistantPath = this.configPath?.configPath;
if (!checkFileExists(assistantPath, true)) {
this.setConfig({
description: '助手配置文件',
});
console.log(chalk.green('助手配置文件创建成功'));
}
}
}

View File

@ -0,0 +1,84 @@
import fs from 'node:fs';
import { AssistantConfig, checkFileExists } from '@/module/assistant/index.ts';
import path from 'node:path';
export const localProxyProxyList = [
{
user: 'root',
key: 'assistant-base-app',
path: '/root/assistant-base-app',
indexPath: 'root/assistant-base-app/index.html',
},
{
user: 'root',
key: 'talkshow-admin',
path: '/root/talkshow-admin',
indexPath: 'root/talkshow-admin/index.html',
},
{
user: 'root',
key: 'center',
path: '/root/center',
indexPath: 'root/center/index.html',
},
];
type ProxyType = {
user: string;
key: string;
path: string;
indexPath: string;
absolutePath?: string;
};
export type LocalProxyOpts = {
assistantConfig?: AssistantConfig; // 前端应用路径
};
export class LocalProxy {
localProxyProxyList: ProxyType[] = [];
assistantConfig?: AssistantConfig;
constructor(opts?: LocalProxyOpts) {
this.assistantConfig = opts?.assistantConfig;
if (this.assistantConfig) {
this.init();
}
}
init() {
const frontAppDir = this.assistantConfig.configPath?.appDir;
console.log('frontAppDir', frontAppDir);
if (frontAppDir) {
const userList = fs.readdirSync(frontAppDir);
const localProxyProxyList: ProxyType[] = [];
userList.forEach((user) => {
const userPath = path.join(frontAppDir, user);
const stat = fs.statSync(userPath);
if (stat.isDirectory()) {
const appList = fs.readdirSync(userPath);
appList.forEach((app) => {
const appPath = path.join(userPath, app);
const indexPath = path.join(appPath, 'index.html');
if (!checkFileExists(indexPath, true)) {
return;
}
// const appPath = `${appPath}/index.html`;
if (checkFileExists(indexPath, true)) {
localProxyProxyList.push({
user: user,
key: app,
path: `/${user}/${app}`,
indexPath: `${user}/${app}/index.html`,
absolutePath: appPath,
});
}
});
}
});
this.localProxyProxyList = localProxyProxyList;
}
}
getLocalProxyList() {
return this.localProxyProxyList;
}
reload() {
// 重新加载本地代理列表
}
}

View File

@ -0,0 +1,85 @@
import { fileProxy, apiProxy, createApiProxy } from '@/module/assistant/index.ts';
import http from 'http';
import { LocalProxy } from './local-proxy.ts';
import { assistantConfig } from '@/app.ts';
import { log } from '@/module/logger.ts';
const localProxy = new LocalProxy({
assistantConfig,
});
export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResponse) => {
const _assistantConfig = assistantConfig.getCacheAssistantConfig();
const appDir = assistantConfig.configPath?.appDir;
const url = new URL(req.url, 'http://localhost');
const pathname = url.pathname;
if (pathname.startsWith('/favicon.ico')) {
res.statusCode = 404;
res.end('Not Found Favicon');
return;
}
if (pathname.startsWith('/client')) {
console.log('handle by router');
return;
}
// client, api, v1, serve 开头的拦截
const apiProxyList = _assistantConfig?.apiProxyList || [];
const defaultApiProxy = createApiProxy(_assistantConfig?.pageApi || 'https://kevisual.cn');
const apiBackendProxy = [...apiProxyList, ...defaultApiProxy].find((item) => pathname.startsWith(item.path));
if (apiBackendProxy) {
console.log('apiBackendProxy', apiBackendProxy, req.url);
return apiProxy(req, res, {
path: apiBackendProxy.path,
target: apiBackendProxy.target,
});
}
const urls = pathname.split('/');
const [_, _user, _app] = urls;
if (!_app) {
res.statusCode = 404;
res.end('Not Found Proxy');
return;
}
if (_app && urls.length === 3) {
// 重定向到
res.writeHead(302, { Location: `${req.url}/` });
return res.end();
}
const proxyApiList = _assistantConfig?.proxy || [];
const proxyApi = proxyApiList.find((item) => pathname.startsWith(item.path));
if (proxyApi) {
log.log('proxyApi', { proxyApi, pathname });
const { user, key } = proxyApi;
return fileProxy(req, res, {
path: proxyApi.path, // 代理路径, 比如/root/center
rootPath: appDir, // 根路径
indexPath: `${user}/${key}/index.html`, // 首页路径
});
}
const localProxyProxyList = localProxy.getLocalProxyList();
const localProxyProxy = localProxyProxyList.find((item) => pathname.startsWith(item.path));
if (localProxyProxy) {
log.log('localProxyProxy', { localProxyProxy, url: req.url });
return fileProxy(req, res, {
path: localProxyProxy.path,
rootPath: assistantConfig.configPath?.appDir,
indexPath: localProxyProxy.indexPath,
});
}
console.log('handle by router 404', req.url);
const creatCenterProxy = createApiProxy(_assistantConfig?.pageApi || 'https://kevisual.cn', ['/root']);
const centerProxy = creatCenterProxy.find((item) => pathname.startsWith(item.path));
if (centerProxy) {
console.log('centerProxy', centerProxy, req.url);
return apiProxy(req, res, {
path: centerProxy.path,
target: centerProxy.target,
type: 'static',
});
}
res.statusCode = 404;
res.end('Not Found Proxy');
// console.log('getCacheAssistantConfig().pageApi', getCacheAssistantConfig().pageApi);
// return apiProxy(req, res, {
// path: url.pathname,
// target: getCacheAssistantConfig().pageApi,
// });
};

32
assistant/tsconfig.json Normal file
View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"module": "nodenext",
"target": "esnext",
"noImplicitAny": false,
"outDir": "./dist",
"sourceMap": false,
"newLine": "LF",
"baseUrl": "./",
"typeRoots": [
"node_modules/@types",
],
"declaration": true,
"noEmit": false,
"allowImportingTsExtensions": true,
"emitDeclarationOnly": true,
"moduleResolution": "NodeNext",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"paths": {
"@/*": [
"src/*"
],
}
},
"include": [
"src/**/*.ts",
],
"exclude": [],
}

0
bin/assistant.js Normal file
View File

View File

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

View File

@ -1,6 +1,7 @@
// @ts-check // @ts-check
// https://bun.sh/docs/bundler // https://bun.sh/docs/bundler
import pkg from './package.json' assert { type: 'json' }; // @ts-ignore
import pkg from './package.json';
// bun run src/index.ts -- // bun run src/index.ts --
await Bun.build({ await Bun.build({
target: 'node', target: 'node',
@ -8,7 +9,7 @@ await Bun.build({
entrypoints: ['./src/index.ts'], entrypoints: ['./src/index.ts'],
outdir: './dist', outdir: './dist',
naming: { naming: {
entry: 'app.mjs', entry: 'envision.mjs',
}, },
define: { define: {

View File

@ -9,23 +9,23 @@
"app": { "app": {
"key": "envision-cli", "key": "envision-cli",
"entry": "dist/app.mjs", "entry": "dist/app.mjs",
"type": "pm2-system-app", "type": "pm2-system-app"
"files": [
"dist"
]
}, },
"bin": { "bin": {
"envision": "bin/envision.js", "envision": "bin/envision.js",
"ev": "bin/envision.js", "ev": "bin/envision.js",
"kv": "bin/envision.js" "assistant": "bin/assistant.js",
"asst": "bin/assistant.js"
}, },
"files": [ "files": [
"dist", "dist",
"bin" "bin",
"bun.config.mjs"
], ],
"scripts": { "scripts": {
"dev": "bun run src/run.ts ", "dev": "bun run src/run.ts ",
"build": "rimraf dist && bun run bun.config.mjs" "build": "rimraf dist && bun run bun.config.mjs",
"dts": "dts-bundle-generator --external-inlines=@types/jsonwebtoken src/index.ts -o dist/index.d.ts "
}, },
"keywords": [ "keywords": [
"kevisual", "kevisual",
@ -49,7 +49,10 @@
"ignore": "^7.0.3", "ignore": "^7.0.3",
"inquirer": "^12.5.2", "inquirer": "^12.5.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"tar": "^7.4.3" "rollup": "^4.40.0",
"rollup-plugin-dts": "^6.2.1",
"tar": "^7.4.3",
"zustand": "^5.0.3"
}, },
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=22.0.0"

665
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +1,4 @@
packages: packages:
- 'submodules/*' - 'submodules/*'
- 'assistant'
- '!submodules/assistant-center'

11
rollup.config.mjs Normal file
View File

@ -0,0 +1,11 @@
import dts from 'rollup-plugin-dts';
export default {
input: 'src/index.ts',
output: {
file: 'dist/index.d.ts',
format: 'es',
},
plugins: [dts()],
};

View File

@ -5,7 +5,6 @@
"noImplicitAny": false, "noImplicitAny": false,
"outDir": "./dist", "outDir": "./dist",
"sourceMap": false, "sourceMap": false,
"allowJs": true,
"newLine": "LF", "newLine": "LF",
"baseUrl": "./", "baseUrl": "./",
"typeRoots": [ "typeRoots": [
@ -18,21 +17,16 @@
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true, "esModuleInterop": true,
"paths": { "paths": {
"@/*": [ "@/*": [
"src/*" "src/*"
], ],
}, }
"resolveJsonModule": true
}, },
"include": [ "include": [
"typings.d.ts",
"src/**/*.ts", "src/**/*.ts",
"./bun.config.mjs"
],
"exclude": [
"node_modules",
"dist",
], ],
"exclude": [],
} }