merge routes-packages
This commit is contained in:
7
assistant/src/module/local-apps/.gitignore
vendored
Normal file
7
assistant/src/module/local-apps/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
/apps
|
||||
dist
|
||||
/logs
|
||||
|
||||
/app.config.json5
|
||||
/app.config.json
|
||||
3
assistant/src/module/local-apps/.npmrc
Normal file
3
assistant/src/module/local-apps/.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
|
||||
@abearxiong:registry=https://npm.pkg.github.com
|
||||
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
||||
87
assistant/src/module/local-apps/apps.config.json
Normal file
87
assistant/src/module/local-apps/apps.config.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"key": "demo",
|
||||
"status": "inactive",
|
||||
"type": "system-app",
|
||||
"description": "",
|
||||
"version": "0.0.1",
|
||||
"runtime": [],
|
||||
"entry": "demo-dist/app.mjs",
|
||||
"path": "/home/ubuntu/kevisual/cli/assistant/src/module/local-apps/apps/demo",
|
||||
"origin": {
|
||||
"key": "demo",
|
||||
"entry": "demo-dist/app.mjs",
|
||||
"type": "system-app"
|
||||
},
|
||||
"timestamp": 1764867997389
|
||||
},
|
||||
{
|
||||
"key": "test-ts",
|
||||
"status": "inactive",
|
||||
"type": "system-app",
|
||||
"description": "",
|
||||
"version": "0.0.1",
|
||||
"runtime": [],
|
||||
"entry": "main.ts",
|
||||
"path": "/home/ubuntu/kevisual/cli/assistant/src/module/local-apps/apps/test-ts",
|
||||
"origin": {
|
||||
"key": "test-ts",
|
||||
"entry": "main.ts",
|
||||
"type": "system-app"
|
||||
},
|
||||
"timestamp": 1764867997389
|
||||
},
|
||||
{
|
||||
"key": "test",
|
||||
"status": "inactive",
|
||||
"type": "pm2-system-app",
|
||||
"description": "",
|
||||
"version": "0.0.3",
|
||||
"runtime": [],
|
||||
"entry": "dist/ws.js",
|
||||
"path": "/home/ubuntu/kevisual/cli/assistant/src/module/local-apps/apps/test",
|
||||
"origin": {
|
||||
"key": "test",
|
||||
"entry": "dist/ws.js",
|
||||
"type": "pm2-system-app"
|
||||
},
|
||||
"pm2Options": {
|
||||
"cwd": "/home/ubuntu/kevisual/cli/assistant/src/module/local-apps"
|
||||
},
|
||||
"timestamp": 1764867997389
|
||||
},
|
||||
{
|
||||
"key": "root/test-log",
|
||||
"status": "inactive",
|
||||
"type": "script-app",
|
||||
"description": "",
|
||||
"version": "0.0.1",
|
||||
"runtime": [],
|
||||
"entry": "main.ts",
|
||||
"path": "/home/ubuntu/kevisual/cli/assistant/src/module/local-apps/apps/root/test-log",
|
||||
"origin": {
|
||||
"key": "root/test-log",
|
||||
"entry": "main.ts",
|
||||
"type": "script-app"
|
||||
},
|
||||
"timestamp": 1764867997389
|
||||
},
|
||||
{
|
||||
"key": "root/test-no",
|
||||
"status": "inactive",
|
||||
"type": "script-app",
|
||||
"description": "",
|
||||
"version": "0.0.1",
|
||||
"runtime": [],
|
||||
"entry": "main.ts",
|
||||
"path": "/home/ubuntu/kevisual/cli/assistant/src/module/local-apps/apps/root/test-no",
|
||||
"origin": {
|
||||
"entry": "main.ts",
|
||||
"type": "script-app",
|
||||
"key": "root/test-no"
|
||||
},
|
||||
"timestamp": 1764868133979
|
||||
}
|
||||
]
|
||||
}
|
||||
45
assistant/src/module/local-apps/bun.config.ts
Normal file
45
assistant/src/module/local-apps/bun.config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { build } from 'bun';
|
||||
|
||||
const external = ['pm2', '@kevisual/router', '@kevisual/use-config'];
|
||||
// https://bun.sh/docs/bundler
|
||||
await Bun.build({
|
||||
target: 'node',
|
||||
format: 'esm',
|
||||
entrypoints: ['./src/app.ts'],
|
||||
outdir: './dist',
|
||||
naming: {
|
||||
entry: 'app.js',
|
||||
},
|
||||
external,
|
||||
minify: false,
|
||||
sourcemap: false
|
||||
});
|
||||
|
||||
const dts = 'dts -i src/app.ts -o app.d.ts';
|
||||
await Bun.spawn({
|
||||
cmd: dts.split(' '),
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
});
|
||||
|
||||
|
||||
await Bun.build({
|
||||
target: 'node',
|
||||
format: 'esm',
|
||||
entrypoints: ['./src/manager.ts'],
|
||||
outdir: './dist',
|
||||
naming: {
|
||||
entry: 'manager.js',
|
||||
},
|
||||
external,
|
||||
minify: false,
|
||||
sourcemap: false
|
||||
});
|
||||
|
||||
const dtsManager = 'dts -i src/manager.ts -o manager.d.ts';
|
||||
await Bun.spawn({
|
||||
cmd: dtsManager.split(' '),
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
});
|
||||
console.log('Build completed.');
|
||||
18
assistant/src/module/local-apps/demos/demo/demo-dist/app.mjs
Normal file
18
assistant/src/module/local-apps/demos/demo/demo-dist/app.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { App } from '@kevisual/router';
|
||||
const app = new App();
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'demo',
|
||||
key: 'get'
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
ctx.body = 'Hello, World!';
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
const loadApp = (mainApp, appInfo) => {
|
||||
mainApp.importApp(app);
|
||||
};
|
||||
|
||||
export { app, loadApp };
|
||||
19
assistant/src/module/local-apps/demos/demo/package.json
Normal file
19
assistant/src/module/local-apps/demos/demo/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "demo",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"main": "demo-dist/app.mjs",
|
||||
"app": {
|
||||
"entry": "demo-dist/app.mjs",
|
||||
"type": "system-app"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"keywords": [
|
||||
"ai"
|
||||
],
|
||||
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||
"license": "MIT",
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Logger } from '@kevisual/logger';
|
||||
|
||||
const logger = new Logger();
|
||||
|
||||
logger.info('This is a test log message from the main.ts file of the test app.');
|
||||
logger.warn('This is a test warning message from the main.ts file of the test app.');
|
||||
logger.error('This is a test error message from the main.ts file of the test app.');
|
||||
logger.debug('This is a test debug message from the main.ts file of the test app.');
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "test-ts",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"basename": "/root/test-log",
|
||||
"app": {
|
||||
"entry": "main.ts",
|
||||
"type": "pm2-system-app"
|
||||
},
|
||||
"files": [
|
||||
"main.ts"
|
||||
],
|
||||
"keywords": []
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Logger } from '@kevisual/logger';
|
||||
|
||||
const logger = new Logger();
|
||||
|
||||
logger.info('This is a test log message from the main.ts file of the test app.');
|
||||
logger.warn('This is a test warning message from the main.ts file of the test app.');
|
||||
logger.error('This is a test error message from the main.ts file of the test app.');
|
||||
logger.debug('This is a test debug message from the main.ts file of the test app.');
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "test-no",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"basename": "/root/test-no",
|
||||
"app": {
|
||||
"entry": "main.ts",
|
||||
"type": "script-app"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
||||
"license": "MIT",
|
||||
"type": "module"
|
||||
}
|
||||
48
assistant/src/module/local-apps/package.json
Normal file
48
assistant/src/module/local-apps/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@kevisual/local-app-manager",
|
||||
"version": "0.1.32",
|
||||
"description": "",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"app": {
|
||||
"entry": "app.js",
|
||||
"type": "system-app"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "bun run src/app.ts",
|
||||
"build": "rimraf dist && bun run bun.config.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@kevisual/router": "0.0.33",
|
||||
"@kevisual/types": "^0.0.10",
|
||||
"@kevisual/use-config": "^1.0.21",
|
||||
"@types/node": "^24.10.1",
|
||||
"fast-glob": "^3.3.3",
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/app.mjs",
|
||||
"types": "./dist/app.d.ts",
|
||||
"require": "./dist/app.mjs"
|
||||
},
|
||||
"./manager": {
|
||||
"import": "./dist/manager.mjs",
|
||||
"types": "./dist/manager.d.ts",
|
||||
"require": "./dist/manager.mjs"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"pm2": "^6.0.14"
|
||||
}
|
||||
}
|
||||
191
assistant/src/module/local-apps/readme.md
Normal file
191
assistant/src/module/local-apps/readme.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# 本地应用加载
|
||||
|
||||
主要目的,加载微应用模块。
|
||||
|
||||
```ts
|
||||
export enum AppType {
|
||||
/**
|
||||
* run in (import way)
|
||||
*/
|
||||
SystemApp = 'system-app',
|
||||
/**
|
||||
* fork 执行
|
||||
*/
|
||||
MicroApp = 'micro-app',
|
||||
GatewayApp = 'gateway-app',
|
||||
/**
|
||||
* pm2 启动
|
||||
*/
|
||||
Pm2SystemApp = 'pm2-system-app'
|
||||
}
|
||||
export type Runtime = 'client' | 'server';
|
||||
export type AppInfo = {
|
||||
key: string;
|
||||
status?: 'inactive' | 'running' | 'stop' | 'error'; // 运行状态
|
||||
version?: string; // 版本
|
||||
type?: AppType; // 默认类型
|
||||
description?: string; // 描述
|
||||
runtime?: Runtime[]; // 运行时
|
||||
timestamp?: number; // 时间戳, 每次更新更新时间戳
|
||||
process?: any; // 进程
|
||||
|
||||
origin?: Record<string, any>; // 原始数据
|
||||
entry?: string; // 入口文件
|
||||
path?: string; // 文件路径
|
||||
env?: Record<string, any>; // 环境变量
|
||||
engine?: string; // runtime, python node deno bun etc
|
||||
/**
|
||||
* pm2 选项, 仅仅当是AppType.Pm2SystemApp的时候生效
|
||||
* pm2 选项可以参考 https://pm2.keymetrics.io/docs/usage/application-declaration/
|
||||
*/
|
||||
pm2Options?: StartOptions; // pm2 选项
|
||||
};
|
||||
```
|
||||
|
||||
pm2 pm2Options
|
||||
|
||||
```ts
|
||||
export interface StartOptions {
|
||||
/**
|
||||
* Enable or disable auto start after process added (default: true).
|
||||
*/
|
||||
autostart?: boolean;
|
||||
/**
|
||||
* Enable or disable auto restart after process failure (default: true).
|
||||
*/
|
||||
autorestart?: boolean;
|
||||
/**
|
||||
* List of exit codes that should allow the process to stop (skip autorestart).
|
||||
*/
|
||||
stop_exit_codes?: number[];
|
||||
/**
|
||||
* An arbitrary name that can be used to interact with (e.g. restart) the process
|
||||
* later in other commands. Defaults to the script name without its extension
|
||||
* (eg “testScript” for “testScript.js”)
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* The path of the script to run
|
||||
*/
|
||||
script?: string;
|
||||
/**
|
||||
* A string or array of strings composed of arguments to pass to the script.
|
||||
*/
|
||||
args?: string | string[];
|
||||
/**
|
||||
* A string or array of strings composed of arguments to call the interpreter process with.
|
||||
* Eg “–harmony” or [”–harmony”,”–debug”]. Only applies if interpreter is something other
|
||||
* than “none” (its “node” by default).
|
||||
*/
|
||||
interpreter_args?: string | string[];
|
||||
/**
|
||||
* The working directory to start the process with.
|
||||
*/
|
||||
cwd?: string;
|
||||
/**
|
||||
* (Default: “~/.pm2/logs/app_name-out.log”) The path to a file to append stdout output to.
|
||||
* Can be the same file as error.
|
||||
*/
|
||||
output?: string;
|
||||
/**
|
||||
* (Default: “~/.pm2/logs/app_name-error.err”) The path to a file to append stderr output to. Can be the same file as output.
|
||||
*/
|
||||
error?: string;
|
||||
/**
|
||||
* The display format for log timestamps (eg “YYYY-MM-DD HH:mm Z”). The format is a moment display format.
|
||||
*/
|
||||
log_date_format?: string;
|
||||
/**
|
||||
* Default: “~/.pm2/logs/~/.pm2/pids/app_name-id.pid”)
|
||||
* The path to a file to write the pid of the started process. The file will be overwritten.
|
||||
* Note that the file is not used in any way by pm2 and so the user is free to manipulate or
|
||||
* remove that file at any time. The file will be deleted when the process is stopped or the daemon killed.
|
||||
*/
|
||||
pid?: string;
|
||||
/**
|
||||
* The minimum uptime of the script before it’s considered successfully started.
|
||||
*/
|
||||
min_uptime?: number;
|
||||
/**
|
||||
* The maximum number of times in a row a script will be restarted if it exits in less than min_uptime.
|
||||
*/
|
||||
max_restarts?: number;
|
||||
/**
|
||||
* If sets and script’s memory usage goes about the configured number, pm2 restarts the script.
|
||||
* Uses human-friendly suffixes: ‘K’ for kilobytes, ‘M’ for megabytes, ‘G’ for gigabytes’, etc. Eg “150M”.
|
||||
*/
|
||||
max_memory_restart?: number | string;
|
||||
/**
|
||||
* Arguments to pass to the interpreter
|
||||
*/
|
||||
node_args?: string | string[];
|
||||
/**
|
||||
* Prefix logs with time
|
||||
*/
|
||||
time?: boolean;
|
||||
/**
|
||||
* This will make PM2 listen for that event. In your application you will need to add process.send('ready');
|
||||
* when you want your application to be considered as ready.
|
||||
*/
|
||||
wait_ready?: boolean;
|
||||
/**
|
||||
* (Default: 1600)
|
||||
* The number of milliseconds to wait after a stop or restart command issues a SIGINT signal to kill the
|
||||
* script forceably with a SIGKILL signal.
|
||||
*/
|
||||
kill_timeout?: number;
|
||||
/**
|
||||
* (Default: 0) Number of millseconds to wait before restarting a script that has exited.
|
||||
*/
|
||||
restart_delay?: number;
|
||||
/**
|
||||
* (Default: “node”) The interpreter for your script (eg “python”, “ruby”, “bash”, etc).
|
||||
* The value “none” will execute the ‘script’ as a binary executable.
|
||||
*/
|
||||
interpreter?: string;
|
||||
/**
|
||||
* (Default: ‘fork’) If sets to ‘cluster’, will enable clustering
|
||||
* (running multiple instances of the script).
|
||||
*/
|
||||
exec_mode?: string;
|
||||
/**
|
||||
* (Default: 1) How many instances of script to create. Only relevant in exec_mode ‘cluster’.
|
||||
*/
|
||||
instances?: number;
|
||||
/**
|
||||
* (Default: false) If true, merges the log files for all instances of script into one stderr log
|
||||
* and one stdout log. Only applies in ‘cluster’ mode. For example, if you have 4 instances of
|
||||
* ‘test.js’ started via pm2, normally you would have 4 stdout log files and 4 stderr log files,
|
||||
* but with this option set to true you would only have one stdout file and one stderr file.
|
||||
*/
|
||||
merge_logs?: boolean;
|
||||
/**
|
||||
* If set to true, the application will be restarted on change of the script file.
|
||||
*/
|
||||
watch?: boolean | string[];
|
||||
/**
|
||||
* (Default: false) By default, pm2 will only start a script if that script isn’t
|
||||
* already running (a script is a path to an application, not the name of an application
|
||||
* already running). If force is set to true, pm2 will start a new instance of that script.
|
||||
*/
|
||||
force?: boolean;
|
||||
ignore_watch?: string[];
|
||||
cron?: any;
|
||||
execute_command?: any;
|
||||
write?: any;
|
||||
source_map_support?: any;
|
||||
disable_source_map_support?: any;
|
||||
/**
|
||||
* The environment variables to pass on to the process.
|
||||
*/
|
||||
env?: { [key: string]: string };
|
||||
/**
|
||||
* NameSpace for the process
|
||||
* @default 'default'
|
||||
* @example 'production'
|
||||
* @example 'development'
|
||||
* @example 'staging'
|
||||
*/
|
||||
namespace?: string;
|
||||
}
|
||||
```
|
||||
3
assistant/src/module/local-apps/src/app.ts
Normal file
3
assistant/src/module/local-apps/src/app.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { App } from '@kevisual/router';
|
||||
import { useContextKey } from '@kevisual/use-config/context';
|
||||
export const app = useContextKey('app', () => new App());
|
||||
43
assistant/src/module/local-apps/src/index.ts
Normal file
43
assistant/src/module/local-apps/src/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { App } from '@kevisual/router';
|
||||
import { app } from './app.ts';
|
||||
|
||||
import { manager, loadManager } from './manager.ts';
|
||||
|
||||
import './routes/list.ts';
|
||||
|
||||
export { app, manager, loadManager };
|
||||
|
||||
if (DEV_SERVER) {
|
||||
app
|
||||
.route({
|
||||
path: 'auth',
|
||||
key: 'admin',
|
||||
id: 'auth-admin'
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
// ctx.body = 'admin';
|
||||
})
|
||||
.addTo(app);
|
||||
app
|
||||
.route({
|
||||
path: 'test',
|
||||
key: 'test'
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
ctx.body = app.router.routes.map((item) => {
|
||||
return {
|
||||
path: item.path,
|
||||
key: item.key
|
||||
};
|
||||
});
|
||||
})
|
||||
.addTo(app);
|
||||
app.listen(9787, () => {
|
||||
console.log('Server is running on port 9787');
|
||||
});
|
||||
loadManager({ configFilename: 'b.json' });
|
||||
}
|
||||
|
||||
export const loadApp = (mainApp: App, appInfo?: any) => {
|
||||
//
|
||||
};
|
||||
23
assistant/src/module/local-apps/src/manager.ts
Normal file
23
assistant/src/module/local-apps/src/manager.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { app } from './app.ts';
|
||||
import { LoadOptions, Manager } from './modules/manager.ts';
|
||||
|
||||
export const manager = new Manager({
|
||||
mainApp: app
|
||||
});
|
||||
|
||||
/**
|
||||
* 加载manager的内容
|
||||
*/
|
||||
export const loadManager = (opts?: LoadOptions) => {
|
||||
const load = () => {
|
||||
manager
|
||||
.load(opts)
|
||||
.then(() => {
|
||||
console.log('load apps success');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('load apps error', err);
|
||||
});
|
||||
};
|
||||
setTimeout(load, 1000);
|
||||
};
|
||||
82
assistant/src/module/local-apps/src/modules/app-file.ts
Normal file
82
assistant/src/module/local-apps/src/modules/app-file.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useFileStore } from '@kevisual/use-config/file-store';
|
||||
import { fileIsExist, getConfigFile } from '@kevisual/use-config/env';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { AppInfo } from './manager.ts';
|
||||
|
||||
export const getAppsPath = () => {
|
||||
const appsPath = process.env.APPS_PATH;
|
||||
if (appsPath) {
|
||||
const resolvePath = path.resolve(appsPath);
|
||||
if (fileIsExist(resolvePath)) {
|
||||
return resolvePath;
|
||||
} else {
|
||||
fs.mkdirSync(resolvePath, { recursive: true });
|
||||
return resolvePath;
|
||||
}
|
||||
}
|
||||
return useFileStore('apps', { needExists: true });
|
||||
};
|
||||
export type AppInfoConfig = {
|
||||
list: AppInfo[];
|
||||
[key: string]: any;
|
||||
};
|
||||
/**
|
||||
* 加载应用信息
|
||||
* @returns
|
||||
*/
|
||||
export const loadAppInfo = async (appsPath: string = 'apps', filename = 'apps.config.json'): Promise<AppInfoConfig> => {
|
||||
let configFile = getConfigFile({
|
||||
cwd: appsPath,
|
||||
fileName: filename
|
||||
});
|
||||
|
||||
if (!configFile) {
|
||||
configFile = path.join(appsPath, '..', filename);
|
||||
fs.writeFileSync(configFile, JSON.stringify({ list: [] }));
|
||||
return { list: [] };
|
||||
}
|
||||
try {
|
||||
const config = fs.readFileSync(configFile, 'utf-8');
|
||||
const v = JSON.parse(config);
|
||||
if (!v.list) {
|
||||
v.list = [];
|
||||
}
|
||||
return v;
|
||||
} catch (e) {
|
||||
console.error('读取配置文件失败', e.message);
|
||||
return { list: [] };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* 保存应用信息
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
export const saveAppInfo = async (data: any, appsPath: string, filename = 'apps.config.json') => {
|
||||
const configFile = getConfigFile({
|
||||
fileName: filename,
|
||||
cwd: appsPath
|
||||
});
|
||||
if (!configFile) {
|
||||
console.error('未找到配置文件');
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(configFile, JSON.stringify(data, null, 2));
|
||||
};
|
||||
/**
|
||||
* 删除应用信息
|
||||
* @param key
|
||||
* @returns
|
||||
*/
|
||||
export const deleteFileAppInfo = async (key: string, appsPath: string) => {
|
||||
// 标准化key中的路径分隔符,统一使用系统路径分隔符
|
||||
const normalizedKey = key.replace(/\//g, path.sep);
|
||||
const directory = path.join(appsPath, normalizedKey);
|
||||
if (!fileIsExist(directory)) {
|
||||
return;
|
||||
}
|
||||
fs.rmSync(directory, { recursive: true });
|
||||
};
|
||||
576
assistant/src/module/local-apps/src/modules/manager.ts
Normal file
576
assistant/src/module/local-apps/src/modules/manager.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
import type { App } from '@kevisual/router';
|
||||
import { loadAppInfo, AppInfoConfig, saveAppInfo, getAppsPath } from './app-file.ts';
|
||||
import { fork } from 'node:child_process';
|
||||
import { merge } from 'lodash-es';
|
||||
import { deleteFileAppInfo } from './app-file.ts';
|
||||
import { fileIsExist } from '@kevisual/use-config/env';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
import glob from 'fast-glob'
|
||||
import { Pm2Manager, Pm2Connect, checkInstall } from './pm2.ts';
|
||||
import type { StartOptions } from 'pm2';
|
||||
export { Pm2Manager, Pm2Connect };
|
||||
// 共享
|
||||
export const existDenpend = [
|
||||
'sequelize', // commonjs
|
||||
'pg', // commonjs
|
||||
'@kevisual/router', // 共享模块
|
||||
'@kevisual/use-config', // 共享模块
|
||||
'ioredis', // commonjs
|
||||
'socket.io', // commonjs
|
||||
'minio', // commonjs
|
||||
];
|
||||
export enum AppType {
|
||||
/**
|
||||
* run in (import way)
|
||||
*/
|
||||
SystemApp = 'system-app',
|
||||
/**
|
||||
* fork 执行
|
||||
*/
|
||||
MicroApp = 'micro-app',
|
||||
GatewayApp = 'gateway-app',
|
||||
/**
|
||||
* pm2 启动
|
||||
*/
|
||||
Pm2SystemApp = 'pm2-system-app',
|
||||
ScriptApp = 'script-app'
|
||||
}
|
||||
export type Runtime = 'client' | 'server';
|
||||
export type AppInfo = {
|
||||
key: string;
|
||||
status?: 'inactive' | 'running' | 'stop' | 'error' | 'finished'; // 运行状态
|
||||
version?: string; // 版本
|
||||
type?: AppType; // 默认类型
|
||||
description?: string; // 描述
|
||||
runtime?: Runtime[]; // 运行时
|
||||
timestamp?: number; // 时间戳, 每次更新更新时间戳
|
||||
process?: any; // 进程
|
||||
|
||||
origin?: Record<string, any>; // 原始数据
|
||||
entry?: string; // 入口文件
|
||||
path?: string; // 文件路径
|
||||
env?: Record<string, any>; // 环境变量
|
||||
engine?: string; // runtime, python node deno bun etc
|
||||
/**
|
||||
* pm2 选项, 仅仅当是AppType.Pm2SystemApp的时候生效
|
||||
* pm2 选项可以参考 https://pm2.keymetrics.io/docs/usage/application-declaration/
|
||||
*/
|
||||
pm2Options?: StartOptions; // pm2 选项
|
||||
};
|
||||
export const onAppShowInfo = (app: AppInfo) => {
|
||||
return {
|
||||
key: app.key,
|
||||
status: app.status,
|
||||
engine: app.engine,
|
||||
type: app.type,
|
||||
description: app.description,
|
||||
version: app.version
|
||||
};
|
||||
};
|
||||
export const createAppShowInfo = (app: any) => {
|
||||
return {
|
||||
key: app.key,
|
||||
status: app.status,
|
||||
type: app.type,
|
||||
engine: app.engine,
|
||||
description: app.description,
|
||||
version: app.version
|
||||
};
|
||||
};
|
||||
type managerOptions = {
|
||||
mainApp?: App;
|
||||
/**
|
||||
* apps文件夹的路径
|
||||
* @default process.env.APPS_PATH
|
||||
* @default useFileStore('apps', { needExists: true })
|
||||
* @example /path/to/apps
|
||||
*/
|
||||
appsPath?: string;
|
||||
/**
|
||||
* apps.config.json的路径
|
||||
* @default appsPath/apps.config.json
|
||||
* @example /path/to/apps/apps.config.json
|
||||
*/
|
||||
configFilename?: string;
|
||||
};
|
||||
export type LoadOptions = { runtime?: Runtime } & managerOptions;
|
||||
export class Manager<T extends AppInfo = AppInfo> {
|
||||
apps: Map<string, T>;
|
||||
mainApp?: App;
|
||||
appInfo: AppInfoConfig;
|
||||
appsPath: string;
|
||||
configFilename: string;
|
||||
constructor(opts: managerOptions) {
|
||||
this.apps = new Map();
|
||||
this.mainApp = opts.mainApp;
|
||||
this.appInfo = {} as any;
|
||||
this.appsPath = opts?.appsPath || getAppsPath();
|
||||
this.configFilename = opts?.configFilename || 'apps.config.json';
|
||||
}
|
||||
#pm2Connect?: Pm2Connect;
|
||||
/**
|
||||
* 检查key是否存在
|
||||
* @param key
|
||||
* @returns
|
||||
*/
|
||||
checkKey(key: string) {
|
||||
return this.apps.has(key);
|
||||
}
|
||||
/*
|
||||
* 获取app信息
|
||||
* @param key
|
||||
*/
|
||||
async loadApp(app: T) {
|
||||
const mainApp = this.mainApp;
|
||||
this.apps.set(app.key, app);
|
||||
if (app.status !== 'running') {
|
||||
return;
|
||||
}
|
||||
if (!fileIsExist(app.path)) {
|
||||
console.error('app is not found');
|
||||
return;
|
||||
}
|
||||
const pathEntry = path.join(app.path, app.entry);
|
||||
if (!fileIsExist(pathEntry)) {
|
||||
console.error('file entry not found');
|
||||
return;
|
||||
}
|
||||
const entry = app.entry + `?timestamp=${app?.timestamp}`;
|
||||
// 注册路由
|
||||
if (app.type === AppType.MicroApp) {
|
||||
const childProcess = fork(app.entry, [], {
|
||||
stdio: 'inherit', // 共享主进程的标准输入输出
|
||||
cwd: app.path,
|
||||
env: {
|
||||
...process.env,
|
||||
...app.env,
|
||||
APP_KEY: app.key,
|
||||
APP_PATH: app.path,
|
||||
APP_ENTRY: entry
|
||||
}
|
||||
});
|
||||
app.process = childProcess;
|
||||
} else if (app.type === AppType.SystemApp) {
|
||||
const pathEntryAndTimestamp = path.join(app.path, entry);
|
||||
// Windows下需要使用file://协议,并将反斜杠转换为正斜杠
|
||||
const importPath = process.platform === 'win32'
|
||||
? 'file:///' + pathEntryAndTimestamp.replace(/\\/g, '/')
|
||||
: pathEntryAndTimestamp;
|
||||
const module = await import(importPath);
|
||||
if (module.loadApp && mainApp) {
|
||||
await module.loadApp?.(mainApp, app);
|
||||
}
|
||||
} else if (app.type === AppType.GatewayApp) {
|
||||
console.log('gateway app not support');
|
||||
} else if (app.type === AppType.Pm2SystemApp) {
|
||||
const pathEntry = path.join(app.path, app.entry);
|
||||
const pm2Manager = new Pm2Manager({
|
||||
appName: app.key,
|
||||
script: pathEntry,
|
||||
pm2Connect: this.#pm2Connect
|
||||
});
|
||||
|
||||
// const isInstall = await checkInstall(app);
|
||||
// if (!isInstall) {
|
||||
// console.log('install failed');
|
||||
// return;
|
||||
// }
|
||||
const pm2Options: StartOptions = app.pm2Options || {};
|
||||
if (app?.engine) {
|
||||
pm2Options.interpreter = pm2Options.interpreter || app?.engine;
|
||||
}
|
||||
if (!pm2Options.cwd) {
|
||||
pm2Options.cwd = path.join(app.path, '../..');
|
||||
}
|
||||
await pm2Manager.start(pm2Options);
|
||||
} else {
|
||||
console.error('app type not support', app.type);
|
||||
}
|
||||
console.log(`load ${app.type} success`, app.key);
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* create new app info
|
||||
* @param app
|
||||
*/
|
||||
async saveAppInfo(app: T, newTimeData = false) {
|
||||
const list = this.appInfo.list || [];
|
||||
if (newTimeData) {
|
||||
app.timestamp = Date.now();
|
||||
}
|
||||
const { process, ...info } = app;
|
||||
const has = list.findIndex((item) => item.key === app.key);
|
||||
|
||||
if (has >= 0) {
|
||||
list[has] = info;
|
||||
} else {
|
||||
list.push(info);
|
||||
}
|
||||
this.appInfo.list = list;
|
||||
await saveAppInfo(this.appInfo, this.appsPath, this.configFilename);
|
||||
}
|
||||
/**
|
||||
* 加载配置
|
||||
* @returns
|
||||
*/
|
||||
async loadConfig(loadApps = false) {
|
||||
if (loadApps) {
|
||||
return await this.load();
|
||||
}
|
||||
const appInfos = await loadAppInfo(this.appsPath, this.configFilename);
|
||||
this.appInfo = appInfos;
|
||||
const list = (appInfos?.list || []) as T[];
|
||||
for (const app of list) {
|
||||
this.apps.set(app.key, app);
|
||||
}
|
||||
return this.apps;
|
||||
}
|
||||
/**
|
||||
* 初始化应用的时候加载
|
||||
*/
|
||||
async load(opts?: LoadOptions) {
|
||||
// 从apps文件夹列表当中中加载app信息
|
||||
if (opts?.configFilename) {
|
||||
this.configFilename = opts.configFilename;
|
||||
}
|
||||
if (opts?.appsPath) {
|
||||
this.appsPath = opts.appsPath;
|
||||
}
|
||||
if (opts?.mainApp) {
|
||||
this.mainApp = opts.mainApp;
|
||||
}
|
||||
const appInfos = await loadAppInfo(this.appsPath, this.configFilename);
|
||||
this.appInfo = appInfos;
|
||||
const _List = (appInfos?.list as T[]) || [];
|
||||
const list = _List.filter((item) => {
|
||||
if (opts?.runtime && item.runtime) {
|
||||
return item.runtime?.includes(opts.runtime);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const pm2Connect = new Pm2Connect(false);
|
||||
this.#pm2Connect = pm2Connect;
|
||||
try {
|
||||
await pm2Connect.connect();
|
||||
} catch (e) {
|
||||
console.log('pm2 connect error', e);
|
||||
return;
|
||||
}
|
||||
for (const app of list) {
|
||||
try {
|
||||
const loaded = await this.loadApp(app);
|
||||
if (!loaded) {
|
||||
// 加载失败,如果是running状态,设置为error
|
||||
if (app.status === 'running') {
|
||||
app.status = 'error';
|
||||
console.log('load app error', app); // save app error info
|
||||
await this.saveAppInfo(app);
|
||||
}
|
||||
} else {
|
||||
// console.log('load app success', app);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`load app error====[${app.type} | ${app.key}]\n`, e);
|
||||
}
|
||||
}
|
||||
pm2Connect.disconnect();
|
||||
this.#pm2Connect = null;
|
||||
}
|
||||
async add(app: T) {
|
||||
if (this.checkKey(app.key)) {
|
||||
console.error('key is loaded');
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.saveAppInfo(app, true);
|
||||
this.loadApp(app);
|
||||
}
|
||||
// 启动
|
||||
async start(key: string) {
|
||||
const app = this.apps.get(key);
|
||||
if (!app) {
|
||||
console.error('app not found', key);
|
||||
return;
|
||||
}
|
||||
if (app.status === 'running' && app.type === AppType.SystemApp) {
|
||||
console.log(`app ${key} is running`);
|
||||
return;
|
||||
}
|
||||
app.status = 'running';
|
||||
this.loadApp(app);
|
||||
await this.saveAppInfo(app);
|
||||
}
|
||||
// 停止
|
||||
async stop(key: string) {
|
||||
const app = this.apps.get(key);
|
||||
if (!app) {
|
||||
return;
|
||||
}
|
||||
if (app.status === 'stop' && app.type === AppType.SystemApp) {
|
||||
console.log(`app ${key} is stopped`);
|
||||
return;
|
||||
}
|
||||
app.status = 'stop';
|
||||
if (app.type === AppType.MicroApp) {
|
||||
if (app.process) {
|
||||
app.process.kill();
|
||||
}
|
||||
}
|
||||
if (app.type === AppType.Pm2SystemApp) {
|
||||
const pm2Manager = new Pm2Manager({
|
||||
appName: app.key,
|
||||
script: app.entry,
|
||||
pm2Connect: this.#pm2Connect
|
||||
});
|
||||
await pm2Manager.stop();
|
||||
}
|
||||
await this.saveAppInfo(app);
|
||||
}
|
||||
async restart(key: string) {
|
||||
const app = this.apps.get(key);
|
||||
if (!app) {
|
||||
return;
|
||||
}
|
||||
if (app.status !== 'running') {
|
||||
await this.start(key);
|
||||
return;
|
||||
}
|
||||
if (app.status === 'running' && app.type === AppType.Pm2SystemApp) {
|
||||
const pm2Manager = new Pm2Manager({
|
||||
appName: app.key
|
||||
});
|
||||
await pm2Manager.restart();
|
||||
return;
|
||||
} else {
|
||||
// 重新启动
|
||||
await this.stop(key);
|
||||
await this.start(key);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取app信息, 用于展示
|
||||
* @param key
|
||||
* @returns
|
||||
*/
|
||||
getAppShowInfo(key: string) {
|
||||
const app = this.apps.get(key);
|
||||
if (!app) {
|
||||
return;
|
||||
}
|
||||
return onAppShowInfo(app);
|
||||
}
|
||||
/**
|
||||
* 获取所有app信息, 用于展示
|
||||
* @returns
|
||||
*/
|
||||
getAllAppShowInfo() {
|
||||
const list = [];
|
||||
for (const [key, value] of this.apps) {
|
||||
list.push(onAppShowInfo(value));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
/**
|
||||
* 更新app信息, 用于展示, 加上一些功能,启动,停止程序
|
||||
* @param key
|
||||
* @param info
|
||||
* @returns
|
||||
*/
|
||||
async updateAppInfo(key: string, info: Partial<T>) {
|
||||
const app = this.apps.get(key);
|
||||
if (!app) {
|
||||
return;
|
||||
}
|
||||
merge(app, info);
|
||||
this.loadApp(app);
|
||||
await this.saveAppInfo(app);
|
||||
return onAppShowInfo(app);
|
||||
}
|
||||
/**
|
||||
* 删除app信息
|
||||
* @param key
|
||||
* @returns
|
||||
*/
|
||||
async removeApp(key: string) {
|
||||
const app = this.apps.get(key);
|
||||
if (!app) {
|
||||
return false;
|
||||
}
|
||||
if (app.process) {
|
||||
app.process.kill();
|
||||
}
|
||||
this.apps.delete(key);
|
||||
this.appInfo.list = this.appInfo.list.filter((item) => item.key !== key);
|
||||
await saveAppInfo(this.appInfo, this.appsPath, this.configFilename);
|
||||
try {
|
||||
if (app.type === AppType.Pm2SystemApp) {
|
||||
const pm2Manager = new Pm2Manager({
|
||||
appName: app.key
|
||||
});
|
||||
await pm2Manager.deleteProcess();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('delete pm2 process error', e);
|
||||
}
|
||||
try {
|
||||
deleteFileAppInfo(key, this.appsPath);
|
||||
} catch (e) {
|
||||
console.error('delete file app error', e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Detect micro app,检测apps的没有加载进来的app模块
|
||||
* @param manager
|
||||
* @returns
|
||||
*/
|
||||
async detectApp(opts: { autoClear?: boolean } = {}) {
|
||||
const manager = this;
|
||||
const { autoClear = true } = opts;
|
||||
const list = manager.getAllAppShowInfo();
|
||||
const appPathKeys = await getAppPathKeys(this.appsPath);
|
||||
let hasDeletedList: any[] = [];
|
||||
console.log('App path keys', appPathKeys);
|
||||
if (autoClear) {
|
||||
hasDeletedList = list.filter((item) => !appPathKeys.find((key) => item.key === key));
|
||||
console.log('Has deleted', hasDeletedList);
|
||||
for (const item of hasDeletedList) {
|
||||
try {
|
||||
await manager.removeApp(item.key);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
const notIn = appPathKeys.filter((key) => !list.find((item) => item.key === key));
|
||||
console.log('Not in', notIn);
|
||||
const loadInfo = [];
|
||||
if (notIn.length <= 0) {
|
||||
return true;
|
||||
}
|
||||
for (const key of notIn) {
|
||||
try {
|
||||
const { showAppInfo } = await installAppFromKey(key, this.appsPath);
|
||||
await manager.add(showAppInfo as any);
|
||||
loadInfo.push(`Load ${key} success`);
|
||||
} catch (e) {
|
||||
loadInfo.push(`Load ${key} error:`, e.message);
|
||||
}
|
||||
}
|
||||
return { list: loadInfo, newApps: notIn, hasDeletedList };
|
||||
}
|
||||
async reload(key: string) {
|
||||
const manager = this;
|
||||
const app = this.apps.get(key);
|
||||
if (!app) {
|
||||
return;
|
||||
}
|
||||
const appStatus = app.status;
|
||||
try {
|
||||
await manager.removeApp(key);
|
||||
} catch (e) {
|
||||
}
|
||||
try {
|
||||
const { showAppInfo } = await installAppFromKey(key, this.appsPath);
|
||||
await manager.add(showAppInfo as any);
|
||||
if (appStatus === 'running') {
|
||||
await manager.start(key);
|
||||
}
|
||||
console.log('reload app success', key);
|
||||
} catch (e) {
|
||||
console.error('reload app error', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装app通过key
|
||||
* @param key
|
||||
* @returns
|
||||
*/
|
||||
export const installAppFromKey = async (key: string, _appPath: string) => {
|
||||
// 标准化key中的路径分隔符,统一使用系统路径分隔符
|
||||
const normalizedKey = key.replace(/\//g, path.sep);
|
||||
const directory = path.join(_appPath, normalizedKey);
|
||||
if (!fileIsExist(directory)) {
|
||||
console.error('App not found', directory);
|
||||
throw new Error('App not found');
|
||||
}
|
||||
const pkgs = path.join(directory, 'package.json');
|
||||
if (!fileIsExist(pkgs)) {
|
||||
throw new Error('Invalid package.json, need package.json in app directory');
|
||||
}
|
||||
const json = fs.readFileSync(pkgs, 'utf-8');
|
||||
const pkg = JSON.parse(json);
|
||||
const { name, version, app } = pkg;
|
||||
if (!name || !version || !app) {
|
||||
console.error('need name, version and app in package.json');
|
||||
throw new Error('Invalid package.json format, need name, version and app');
|
||||
}
|
||||
const readmeFile = path.join(directory, 'README.md');
|
||||
const readmeFile2 = path.join(directory, 'readme.md');
|
||||
let readmeDesc = '';
|
||||
if (fileIsExist(readmeFile)) {
|
||||
readmeDesc = fs.readFileSync(readmeFile, 'utf-8');
|
||||
} else if (fileIsExist(readmeFile2)) {
|
||||
readmeDesc = fs.readFileSync(readmeFile2, 'utf-8');
|
||||
}
|
||||
let showAppInfo: AppInfo = {
|
||||
key,
|
||||
status: 'inactive',
|
||||
type: app?.type || 'system-app',
|
||||
description: app?.description || pkg?.description || readmeDesc || '',
|
||||
version,
|
||||
runtime: app?.runtime || [],
|
||||
//
|
||||
entry: app?.entry || '',
|
||||
path: directory,
|
||||
origin: app
|
||||
};
|
||||
app.key = key;
|
||||
if (app.type === 'pm2-system-app') {
|
||||
const pm2Cwd = path.join(_appPath, '..');
|
||||
showAppInfo.pm2Options = { cwd: pm2Cwd, ...app.pm2Options };
|
||||
}
|
||||
return { pkg, showAppInfo };
|
||||
};
|
||||
/**
|
||||
* 读取apps文件夹下的所有文件夹,对filename进行过滤
|
||||
* @returns
|
||||
*/
|
||||
export const getAppPathKeys = async (_appPath: string) => {
|
||||
const directory = path.resolve(_appPath);
|
||||
const root = directory;
|
||||
// 使用 posix 风格路径用于 glob 模式,确保跨平台兼容
|
||||
const path1 = '*/package.json';
|
||||
const path2 = '*/*/package.json';
|
||||
const appsPackages = await glob([path1, path2], {
|
||||
cwd: root,
|
||||
onlyFiles: true,
|
||||
absolute: false,
|
||||
ignore: ['**/node_modules/**']
|
||||
});
|
||||
const appPathKeys = appsPackages.map((pkg) => {
|
||||
const dir = path.dirname(pkg);
|
||||
// 直接使用dirname的结果,因为glob已经返回相对于root的路径
|
||||
// 只需要标准化路径分隔符
|
||||
return dir.replace(/\\/g, '/');
|
||||
});
|
||||
return appPathKeys;
|
||||
};
|
||||
|
||||
// TODO
|
||||
export const clearMicroApp = (link: string) => {
|
||||
try {
|
||||
const moduleUrl = new URL(link, import.meta.url);
|
||||
// 使用 fileURLToPath 确保 Windows 和 Unix 路径兼容
|
||||
const modulePath = fileURLToPath(moduleUrl);
|
||||
delete require.cache[modulePath];
|
||||
console.log(`Module ${link} has been unloaded.`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to unload module ${link}:`, error);
|
||||
}
|
||||
};
|
||||
243
assistant/src/module/local-apps/src/modules/pm2.ts
Normal file
243
assistant/src/module/local-apps/src/modules/pm2.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import pm2, { ProcessDescription, StartOptions } from 'pm2';
|
||||
import { promisify } from 'node:util';
|
||||
import type { AppInfo } from './manager.ts';
|
||||
|
||||
/**
|
||||
* 规范化脚本路径,确保在 Windows 平台上兼容 PM2
|
||||
* @param scriptPath 脚本路径
|
||||
* @returns 规范化后的路径
|
||||
*/
|
||||
export const normalizeScriptPath = (scriptPath: string): string => {
|
||||
if (process.platform === 'win32') {
|
||||
// 在 Windows 上,将反斜杠转换为正斜杠,PM2 更好地支持正斜杠
|
||||
return scriptPath.replace(/\\/g, '/');
|
||||
}
|
||||
return scriptPath;
|
||||
};
|
||||
|
||||
export const connect = async (noDaemonMode: boolean = false) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
pm2.connect(noDaemonMode, (err) => {
|
||||
if (err) {
|
||||
console.error('pm2 connect error', err);
|
||||
return reject(err);
|
||||
}
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
};
|
||||
export const disconnect = promisify(pm2.disconnect).bind(pm2);
|
||||
export const start = promisify(pm2.start).bind(pm2);
|
||||
export const stop = promisify(pm2.stop).bind(pm2);
|
||||
export const restart = promisify(pm2.restart).bind(pm2);
|
||||
export const reload = promisify(pm2.reload).bind(pm2);
|
||||
const deleteProcess = promisify(pm2.delete).bind(pm2);
|
||||
const list = promisify(pm2.list).bind(pm2);
|
||||
export type Pm2Opts = {
|
||||
appName: string;
|
||||
script?: string;
|
||||
pm2Connect?: Pm2Connect;
|
||||
/**
|
||||
* 启动程序的路径,比如node deno bun interpreter
|
||||
*/
|
||||
interpreter?: string;
|
||||
env?: Record<string, any>;
|
||||
};
|
||||
export const checkInstall = async (app: AppInfo) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 启动前,先执行pnpm install , 如果失败,则不启动
|
||||
const install = spawn('pnpm', ['install'], {
|
||||
cwd: app.path,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
install.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
console.log('install failed');
|
||||
return resolve(false);
|
||||
}
|
||||
console.log('install success');
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
};
|
||||
export class Pm2Connect {
|
||||
needConnect = true;
|
||||
isConnected = false;
|
||||
constructor(needConnect = true) {
|
||||
this.needConnect = needConnect;
|
||||
}
|
||||
async sleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
async checkConnect() {
|
||||
const that = this;
|
||||
try {
|
||||
if (this.needConnect && !this.isConnected) {
|
||||
const data = await connect();
|
||||
that.isConnected = !!data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('pm2 check connect error', e);
|
||||
}
|
||||
}
|
||||
async checkDisconnect(runOpts?: RunOptions) {
|
||||
const needExit = runOpts?.needExit ?? true;
|
||||
if (this.needConnect && this.isConnected && needExit) {
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
async connect() {
|
||||
try {
|
||||
await connect();
|
||||
this.isConnected = true;
|
||||
} catch (e) {
|
||||
console.log('pm2 connect error', e);
|
||||
}
|
||||
}
|
||||
async disconnect() {
|
||||
try {
|
||||
pm2.disconnect();
|
||||
await this.sleep(1000);
|
||||
} catch (e) {
|
||||
console.log('pm2 disconnect error', e);
|
||||
}
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
type RunOptions = {
|
||||
needExit?: boolean;
|
||||
};
|
||||
export class Pm2Manager {
|
||||
pm2Connect: Pm2Connect;
|
||||
/**
|
||||
* app name
|
||||
*/
|
||||
appName: string;
|
||||
/**
|
||||
* 启动脚本的路径
|
||||
*/
|
||||
script?: string;
|
||||
/**
|
||||
* 启动脚本的路径
|
||||
*/
|
||||
interpreter?: string;
|
||||
/**
|
||||
* 批量更新的时候不需要一直connect和关闭
|
||||
*/
|
||||
needConnect = true;
|
||||
isConnect = false;
|
||||
env: any;
|
||||
constructor(opts: Pm2Opts) {
|
||||
this.appName = opts.appName;
|
||||
this.script = opts.script;
|
||||
this.interpreter = opts.interpreter;
|
||||
this.pm2Connect = opts.pm2Connect || new Pm2Connect();
|
||||
this.env = opts.env || {};
|
||||
}
|
||||
|
||||
async list(runOpts?: RunOptions): Promise<ProcessDescription[]> {
|
||||
const _runOpts = { needExit: false, ...runOpts };
|
||||
try {
|
||||
await this.pm2Connect.checkConnect();
|
||||
const apps = await list();
|
||||
return apps;
|
||||
} catch (e) {
|
||||
console.log('pm2 run error', e);
|
||||
return [];
|
||||
} finally {
|
||||
this.pm2Connect.checkDisconnect(_runOpts);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 启动
|
||||
*/
|
||||
async start(options?: StartOptions, runOpts?: RunOptions) {
|
||||
const { appName, script, env, interpreter } = this;
|
||||
const needExit = runOpts?.needExit ?? true;
|
||||
if (!script) {
|
||||
console.error('script is required');
|
||||
return;
|
||||
}
|
||||
// 规范化脚本路径以兼容 Windows
|
||||
const normalizedScript = normalizeScriptPath(script);
|
||||
const starter: StartOptions = {
|
||||
name: appName,
|
||||
script: normalizedScript, // examples: ./agent/main.ts
|
||||
// execute_command: execPath,
|
||||
cwd: process.cwd(),
|
||||
interpreter: interpreter,
|
||||
...options,
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
...env,
|
||||
...options?.env
|
||||
}
|
||||
};
|
||||
try {
|
||||
await this.pm2Connect.checkConnect();
|
||||
const apps = await this.list({ needExit: false });
|
||||
const app = apps.find((app) => app.name === appName);
|
||||
if (app && app.pid === 0) {
|
||||
await start(starter);
|
||||
return;
|
||||
} else if (!app) {
|
||||
await start(starter);
|
||||
} else {
|
||||
console.log(`pm2 app ${appName} is running`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error', e);
|
||||
} finally {
|
||||
this.pm2Connect.checkDisconnect({ needExit });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止
|
||||
*/
|
||||
async stop(runOpts?: RunOptions) {
|
||||
try {
|
||||
await this.pm2Connect.checkConnect();
|
||||
const apps = await this.list({ needExit: false });
|
||||
const app = apps.find((app) => app.name === this.appName);
|
||||
if (app && app.pid !== 0) {
|
||||
await stop(app.name);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error', e);
|
||||
} finally {
|
||||
this.pm2Connect.checkDisconnect(runOpts);
|
||||
}
|
||||
}
|
||||
async restart(runOpts?: RunOptions) {
|
||||
try {
|
||||
await this.pm2Connect.checkConnect();
|
||||
const apps = await this.list({ needExit: false });
|
||||
const app = apps.find((app) => app.name === this.appName);
|
||||
if (app) {
|
||||
await restart(app.name);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error', e);
|
||||
} finally {
|
||||
this.pm2Connect.checkDisconnect(runOpts);
|
||||
}
|
||||
}
|
||||
async deleteProcess(runOpts?: RunOptions) {
|
||||
try {
|
||||
await this.pm2Connect.checkConnect();
|
||||
const apps = await this.list({ needExit: false });
|
||||
const app = apps.find((app) => app.name === this.appName);
|
||||
if (app) {
|
||||
await deleteProcess(app.name);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error', e);
|
||||
} finally {
|
||||
this.pm2Connect.checkDisconnect(runOpts);
|
||||
}
|
||||
}
|
||||
}
|
||||
115
assistant/src/module/local-apps/src/routes/list.ts
Normal file
115
assistant/src/module/local-apps/src/routes/list.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { app } from '../app.ts';
|
||||
import { manager } from '../manager.ts';
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'local-apps',
|
||||
key: 'detect',
|
||||
description: 'Detect local apps',
|
||||
middleware: ['auth-admin']
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const res = await manager.detectApp();
|
||||
ctx.body = res;
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'local-apps',
|
||||
key: 'list',
|
||||
middleware: ['auth-admin'],
|
||||
description: 'List local apps'
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const list = manager.getAllAppShowInfo();
|
||||
ctx.body = list;
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'local-apps',
|
||||
key: 'updateStatus',
|
||||
middleware: ['auth-admin'],
|
||||
description: 'Update app status, start or stop, parmas: status, appKey'
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { status, appKey } = ctx.query;
|
||||
if (!status || !appKey) {
|
||||
ctx.body = 'status or key is required';
|
||||
return;
|
||||
}
|
||||
const app = manager.apps.get(appKey);
|
||||
if (!app) {
|
||||
ctx.throw(404, 'App not found');
|
||||
}
|
||||
if (status === 'start') {
|
||||
await manager.start(appKey);
|
||||
} else if (status === 'stop') {
|
||||
await manager.stop(appKey);
|
||||
}
|
||||
const appShow = manager.getAppShowInfo(appKey);
|
||||
ctx.body = appShow;
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'local-apps',
|
||||
key: 'operate',
|
||||
middleware: ['auth-admin'],
|
||||
description: 'Operate app, parmas: appKey, action: start, stop, restart, removeApp, reload'
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { appKey, action } = ctx.query;
|
||||
if (!appKey || !action) {
|
||||
ctx.throw(400, 'appKey or action is required');
|
||||
}
|
||||
if (!['start', 'stop', 'restart', 'removeApp', 'reload'].includes(action)) {
|
||||
ctx.throw(400, 'action is invalid');
|
||||
}
|
||||
|
||||
const app = manager.apps.get(appKey);
|
||||
if (!app) {
|
||||
ctx.throw(404, 'App not found');
|
||||
}
|
||||
await manager[action](appKey);
|
||||
const appShow = manager.getAppShowInfo(appKey);
|
||||
ctx.body = appShow;
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'local-apps',
|
||||
key: 'update',
|
||||
middleware: ['auth-admin'],
|
||||
description: 'Update app info'
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { key } = ctx.query.data || {};
|
||||
if (!key) {
|
||||
ctx.body = 'key is required';
|
||||
return;
|
||||
}
|
||||
const appInfo = await manager.updateAppInfo(key, ctx.query.data);
|
||||
ctx.body = appInfo;
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'local-apps',
|
||||
key: 'delete',
|
||||
middleware: ['auth-admin']
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { appKey } = ctx.query;
|
||||
if (!appKey) {
|
||||
ctx.throw(400, 'key is required');
|
||||
}
|
||||
const res = await manager.removeApp(appKey);
|
||||
ctx.body = res ? 'ok' : 'fail';
|
||||
})
|
||||
.addTo(app);
|
||||
31
assistant/src/module/local-apps/src/uitls/npm.ts
Normal file
31
assistant/src/module/local-apps/src/uitls/npm.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
export const checkPnpm = () => {
|
||||
try {
|
||||
spawnSync('pnpm', ['--version']);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
type InstallDepsOptions = {
|
||||
appPath: string;
|
||||
isProduction?: boolean;
|
||||
sync?: boolean;
|
||||
};
|
||||
export const installDeps = (opts: InstallDepsOptions) => {
|
||||
const { appPath } = opts;
|
||||
const isProduction = opts.isProduction ?? true;
|
||||
const params = ['i'];
|
||||
if (isProduction) {
|
||||
params.push('--production');
|
||||
}
|
||||
console.log('installDeps', appPath, params);
|
||||
const syncSpawn = opts.sync ? spawnSync : spawn;
|
||||
if (checkPnpm()) {
|
||||
syncSpawn('pnpm', params, { cwd: appPath, stdio: 'inherit', env: process.env });
|
||||
} else {
|
||||
syncSpawn('npm', params, { cwd: appPath, stdio: 'inherit', env: process.env });
|
||||
}
|
||||
};
|
||||
11
assistant/src/module/local-apps/test/deno.ts
Normal file
11
assistant/src/module/local-apps/test/deno.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
console.log('Hello, world!');
|
||||
import http from 'node:http';
|
||||
// @ts-ignore
|
||||
const isDeno = typeof Deno !== 'undefined' && Deno?.version?.deno;
|
||||
console.log('isDeno', isDeno);
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
res.end('Hello, world!');
|
||||
});
|
||||
|
||||
server.listen(8010);
|
||||
13
assistant/src/module/local-apps/test/detect.ts
Normal file
13
assistant/src/module/local-apps/test/detect.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// import { manager } from '../src/manager';
|
||||
import { Manager } from '../src/modules/manager.ts';
|
||||
|
||||
export const manager = new Manager({
|
||||
// mainApp: app
|
||||
});
|
||||
const main = async () => {
|
||||
await manager.loadConfig();
|
||||
const res = await manager.detectApp();
|
||||
console.log(res);
|
||||
};
|
||||
|
||||
main();
|
||||
7
assistant/src/module/local-apps/test/list.ts
Normal file
7
assistant/src/module/local-apps/test/list.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { manager } from '../src/manager';
|
||||
const main = async () => {
|
||||
const res = manager.getAllAppShowInfo();
|
||||
console.log(res);
|
||||
};
|
||||
|
||||
main();
|
||||
19
assistant/src/module/local-apps/test/load-app.ts
Normal file
19
assistant/src/module/local-apps/test/load-app.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { loadAppInfo } from '../src/modules/app-file';
|
||||
import { Manager } from '../src/modules/manager.ts';
|
||||
|
||||
|
||||
const main = async () => {
|
||||
const appInfo = await loadAppInfo("apps");
|
||||
console.log(appInfo);
|
||||
};
|
||||
// main();
|
||||
|
||||
const start = async () => {
|
||||
const manager = new Manager({
|
||||
// mainApp: app
|
||||
});
|
||||
await manager.loadConfig()
|
||||
await manager.start('root/test-log')
|
||||
}
|
||||
|
||||
start()
|
||||
21
assistant/src/module/local-apps/test/pm2-start-test.ts
Normal file
21
assistant/src/module/local-apps/test/pm2-start-test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Pm2Manager, connect, disconnect } from '../src/modules/pm2.ts';
|
||||
|
||||
try {
|
||||
const pm2 = new Pm2Manager({
|
||||
appName: 'test2',
|
||||
script: 'test/deno.ts',
|
||||
interpreter: 'deno'
|
||||
});
|
||||
const main = async () => {
|
||||
console.log('pm2 start test2');
|
||||
await pm2.start({
|
||||
// args: ['--allow-net']
|
||||
interpreter_args: ['-A']
|
||||
});
|
||||
console.log('pm2 disconnect test2');
|
||||
console.log('pm2', pm2.isConnect);
|
||||
};
|
||||
main();
|
||||
} catch (e) {
|
||||
console.error('err', e);
|
||||
}
|
||||
26
assistant/src/module/local-apps/test/pm2.ts
Normal file
26
assistant/src/module/local-apps/test/pm2.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Pm2Manager } from '../src/modules/pm2';
|
||||
|
||||
const manager = new Pm2Manager({
|
||||
appName: 'test',
|
||||
script: 'test.js'
|
||||
});
|
||||
|
||||
const main = async () => {
|
||||
const list = await manager.list();
|
||||
console.log('list', list);
|
||||
// manager.restart()
|
||||
};
|
||||
|
||||
// main();
|
||||
|
||||
const pm2Demo = new Pm2Manager({
|
||||
appName: 'pm2-demo'
|
||||
});
|
||||
|
||||
const pm2Restart = async () => {
|
||||
const list = await pm2Demo.list();
|
||||
console.log('list', list);
|
||||
pm2Demo.restart()
|
||||
}
|
||||
|
||||
pm2Restart();
|
||||
16
assistant/src/module/local-apps/test/start-test-ts.ts
Normal file
16
assistant/src/module/local-apps/test/start-test-ts.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// import { manager } from '../src/manager';
|
||||
import { Manager } from '../src/modules/manager.ts';
|
||||
|
||||
export const manager = new Manager({
|
||||
// mainApp: app
|
||||
});
|
||||
const main = async () => {
|
||||
await manager.loadConfig(true);
|
||||
// const res = await manager.detectApp();
|
||||
// console.log(res);
|
||||
const res = await manager.start('root/test-log');
|
||||
// const res = await manager.stop('root/test-log');
|
||||
console.log(res);
|
||||
};
|
||||
|
||||
main();
|
||||
33
assistant/src/module/local-apps/tsconfig.json
Normal file
33
assistant/src/module/local-apps/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"target": "esnext",
|
||||
"noImplicitAny": false,
|
||||
"outDir": "./dist",
|
||||
"sourceMap": false,
|
||||
"allowJs": true,
|
||||
"newLine": "LF",
|
||||
"baseUrl": "./",
|
||||
"typeRoots": [
|
||||
"node_modules/@types",
|
||||
"node_modules/@kevisual/types"
|
||||
],
|
||||
"declaration": true,
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"moduleResolution": "NodeNext",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
],
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"typings.d.ts",
|
||||
"src/**/*.ts",
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user