merge routes-packages

This commit is contained in:
2025-12-05 01:12:24 +08:00
parent 3064682514
commit b6bdfd872a
28 changed files with 1728 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
node_modules
/apps
dist
/logs
/app.config.json5
/app.config.json

View 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}

View 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
}
]
}

View 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.');

View 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 };

View 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"
}

View File

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

View File

@@ -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": []
}

View File

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

View File

@@ -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"
}

View 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"
}
}

View 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 its 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 scripts 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 isnt
* 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;
}
```

View File

@@ -0,0 +1,3 @@
import { App } from '@kevisual/router';
import { useContextKey } from '@kevisual/use-config/context';
export const app = useContextKey('app', () => new App());

View 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) => {
//
};

View 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);
};

View 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 });
};

View 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);
}
};

View 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);
}
}
}

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

View 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 });
}
};

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

View 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();

View File

@@ -0,0 +1,7 @@
import { manager } from '../src/manager';
const main = async () => {
const res = manager.getAllAppShowInfo();
console.log(res);
};
main();

View 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()

View 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);
}

View 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();

View 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();

View 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",
],
}