diff --git a/packages/plugin-multi-process-app/.npmignore b/packages/plugin-multi-process-app/.npmignore new file mode 100644 index 0000000000..65f5e8779f --- /dev/null +++ b/packages/plugin-multi-process-app/.npmignore @@ -0,0 +1,2 @@ +/node_modules +/src diff --git a/packages/plugin-multi-process-app/README.md b/packages/plugin-multi-process-app/README.md new file mode 100644 index 0000000000..26eaf445d8 --- /dev/null +++ b/packages/plugin-multi-process-app/README.md @@ -0,0 +1 @@ +# @tachybase/multi-process-app diff --git a/packages/plugin-multi-process-app/client.d.ts b/packages/plugin-multi-process-app/client.d.ts new file mode 100644 index 0000000000..6c459cbac4 --- /dev/null +++ b/packages/plugin-multi-process-app/client.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client'; +export { default } from './dist/client'; diff --git a/packages/plugin-multi-process-app/client.js b/packages/plugin-multi-process-app/client.js new file mode 100644 index 0000000000..b6e3be70e6 --- /dev/null +++ b/packages/plugin-multi-process-app/client.js @@ -0,0 +1 @@ +module.exports = require('./dist/client/index.js'); diff --git a/packages/plugin-multi-process-app/package.json b/packages/plugin-multi-process-app/package.json new file mode 100644 index 0000000000..d3515c29d6 --- /dev/null +++ b/packages/plugin-multi-process-app/package.json @@ -0,0 +1,18 @@ +{ + "name": "@tachybase/plugin-multi-process-app", + "version": "0.23.54", + "main": "dist/server/index.js", + "dependencies": { + "koa-proxies": "^0.12.4" + }, + "devDependencies": { + "koa": "^2.15.3" + }, + "peerDependencies": { + "@tachybase/client": "workspace:*", + "@tachybase/server": "workspace:*", + "@tachybase/test": "workspace:*" + }, + "description.zh-CN": "创建各自独立进程的子应用,通过主应用反向代理请求到子应用,并可以通过主应用统一管理子应用启动关闭", + "displayName.zh-CN": "多进程应用" +} diff --git a/packages/plugin-multi-process-app/server.d.ts b/packages/plugin-multi-process-app/server.d.ts new file mode 100644 index 0000000000..c41081ddc6 --- /dev/null +++ b/packages/plugin-multi-process-app/server.d.ts @@ -0,0 +1,2 @@ +export * from './dist/server'; +export { default } from './dist/server'; diff --git a/packages/plugin-multi-process-app/server.js b/packages/plugin-multi-process-app/server.js new file mode 100644 index 0000000000..972842039a --- /dev/null +++ b/packages/plugin-multi-process-app/server.js @@ -0,0 +1 @@ +module.exports = require('./dist/server/index.js'); diff --git a/packages/plugin-multi-process-app/src/client/index.ts b/packages/plugin-multi-process-app/src/client/index.ts new file mode 100644 index 0000000000..b68aea57f9 --- /dev/null +++ b/packages/plugin-multi-process-app/src/client/index.ts @@ -0,0 +1 @@ +export { default } from './plugin'; diff --git a/packages/plugin-multi-process-app/src/client/plugin.tsx b/packages/plugin-multi-process-app/src/client/plugin.tsx new file mode 100644 index 0000000000..5869098362 --- /dev/null +++ b/packages/plugin-multi-process-app/src/client/plugin.tsx @@ -0,0 +1,21 @@ +import { Plugin } from '@tachybase/client'; + +class MultiProcessAppClient extends Plugin { + async afterAdd() { + // await this.app.pm.add() + } + + async beforeLoad() {} + + // You can get and modify the app instance here + async load() { + console.log(this.app); + // this.app.addComponents({}) + // this.app.addScopes({}) + // this.app.addProvider() + // this.app.addProviders() + // this.app.router.add() + } +} + +export default MultiProcessAppClient; diff --git a/packages/plugin-multi-process-app/src/index.ts b/packages/plugin-multi-process-app/src/index.ts new file mode 100644 index 0000000000..7e74612df8 --- /dev/null +++ b/packages/plugin-multi-process-app/src/index.ts @@ -0,0 +1,2 @@ +export * from './server'; +export { default } from './server'; diff --git a/packages/plugin-multi-process-app/src/server/ProcessAppManager.ts b/packages/plugin-multi-process-app/src/server/ProcessAppManager.ts new file mode 100644 index 0000000000..1717f985a0 --- /dev/null +++ b/packages/plugin-multi-process-app/src/server/ProcessAppManager.ts @@ -0,0 +1,122 @@ +import { Repository } from '@tachybase/database'; +import Application from '@tachybase/server'; + +import Koa from 'koa'; +import proxy from 'koa-proxies'; + +import { getPidByPort, runCommand, runCommandAsync } from './utils'; + +export class ProcessAppManager { + private pidMap: Record = {}; + private middlewares: Record = {}; + + private repo: Repository; + transportMap: Record = {}; + + constructor(public mainApp: Application) { + this.repo = this.mainApp.db.getRepository('process_apps'); + } + + // app,afterStart之后要启动子应用 + async afterStart() { + const appList = await this.repo.find({ + filter: { + enabled: true, + }, + }); + for (const appItem of appList) { + const proxyFunc = proxy('/', { + target: appItem.host, + changeOrigin: true, + }); + this.mainApp.use(proxyFunc); + this.middlewares[appItem.id] = proxyFunc; + } + + this.mainApp.use(async (ctx, next) => { + const host = ctx.host; // 获取请求的 Host,如 api.example.com + + const subdomains = Object.keys(this.transportMap); + // 查找匹配的 target + const matchedRule = subdomains.find((subdomain) => new RegExp(`^${subdomain}\\.`).test(host)); + + const target = `http://127.0.0.1:${this.transportMap[matchedRule]}`; + + if (matchedRule) { + return proxy('/', { + target, + changeOrigin: true, + })(ctx, next); + } + + return next(); + }); + } + + async addListener() {} + + async startApp(remote: string, localPath: string, branch: string = 'main', prId = 0) { + // TODO: git clone加速 + if (remote) { + await runCommand(`git clone --branch ${branch} --single-branch ${remote} ${localPath}`); + } + const APP_PORT = `${prId + 15_000}`; + const DB_DATABASE = `pr_${prId}`; + // TODO: install 加速 + await runCommand(`pnpm install && pnpm build`, localPath); + const pid = await runCommandAsync('pnpm', ['start', '--quickstart'], localPath, { + DB_DATABASE, + APP_PORT, + }); + const insertRecord = await this.repo.create({ + values: { + enabled: true, + pid, + database: DB_DATABASE, + port: +APP_PORT, + remote, + branch, + cname: `${prId}`, + }, + }); + // 保存到数据库, 并指定端口,数据库 + this.pidMap[insertRecord.id] = pid; + } + + async refreshApp(remote: string, localPath: string, branch: string = 'main', prId = 0) {} + + async stopApp(id: number) { + const repo = this.mainApp.db.getRepository('process_apps'); + let pid = this.pidMap[id]; + if (!pid) { + const appItem = await repo.findOne({ + filter: { + id: true, + }, + }); + const port = appItem.port; + if (!port) { + return; + } + pid = await getPidByPort(port); + } + + if (!pid) { + this.mainApp.logger.error(`can not find pid, id: ${id}`); + } + + try { + process.kill(pid); + } catch (e) { + this.mainApp.logger.error(e); + return; + } + + await repo.destroy({ + filter: { + id, + }, + }); + delete this.pidMap[id]; + } +} diff --git a/packages/plugin-multi-process-app/src/server/collections/process-apps.ts b/packages/plugin-multi-process-app/src/server/collections/process-apps.ts new file mode 100644 index 0000000000..27f0fbcaaf --- /dev/null +++ b/packages/plugin-multi-process-app/src/server/collections/process-apps.ts @@ -0,0 +1,56 @@ +import { defineCollection } from '@tachybase/database'; + +export default defineCollection({ + dumpRules: 'required', + name: 'process_apps', + autoGenId: false, + sortable: 'sort', + filterTargetKey: 'name', + createdBy: true, + updatedBy: true, + fields: [ + { + name: 'id', + type: 'bigInt', + autoIncrement: true, + primaryKey: true, + allowNull: false, + interface: 'id', + }, + { + type: 'boolean', + name: 'enabled', + }, + { + type: 'string', + name: 'displayName', + }, + { + type: 'string', + name: 'cname', //域名前缀 + unique: true, + }, + { + type: 'string', + name: 'remote', + }, + { + type: 'string', + name: 'branch', + }, + { + type: 'number', + name: 'port', + }, + { + type: 'number', + name: 'pid', + }, + ], + indexes: [ + { + unique: true, + fields: ['database'], + }, + ], +}); diff --git a/packages/plugin-multi-process-app/src/server/index.ts b/packages/plugin-multi-process-app/src/server/index.ts new file mode 100644 index 0000000000..b68aea57f9 --- /dev/null +++ b/packages/plugin-multi-process-app/src/server/index.ts @@ -0,0 +1 @@ +export { default } from './plugin'; diff --git a/packages/plugin-multi-process-app/src/server/plugin.ts b/packages/plugin-multi-process-app/src/server/plugin.ts new file mode 100644 index 0000000000..40ab20eb05 --- /dev/null +++ b/packages/plugin-multi-process-app/src/server/plugin.ts @@ -0,0 +1,33 @@ +import { Plugin } from '@tachybase/server'; + +import proxy from 'koa-proxies'; + +export class MultiProcessAppServer extends Plugin { + async afterAdd() {} + + async beforeLoad() {} + + async load() { + const repo = this.db.getRepository('process_apps'); + const appList = await repo.find(); + const middlewares: Record = {}; + for (const appItem of appList) { + const proxyFunc = proxy('/', { + target: appItem.host, + changeOrigin: true, + }); + this.app.use(proxyFunc); + middlewares[appItem.id] = proxyFunc; + } + } + + async install() {} + + async afterEnable() {} + + async afterDisable() {} + + async remove() {} +} + +export default MultiProcessAppServer; diff --git a/packages/plugin-multi-process-app/src/server/utils.ts b/packages/plugin-multi-process-app/src/server/utils.ts new file mode 100644 index 0000000000..b05130887f --- /dev/null +++ b/packages/plugin-multi-process-app/src/server/utils.ts @@ -0,0 +1,101 @@ +import { exec, execSync, spawn } from 'child_process'; + +/** + * 同步执行 Shell 命令 + * @param command 要执行的完整命令字符串 + * @param cwd 运行命令的目录 + */ +export function runCommand(command: string, cwd?: string): void { + try { + console.log(`执行命令: ${command} (目录: ${cwd || '当前目录'})`); + execSync(command, { stdio: 'inherit', cwd }); + } catch (error) { + console.error(`命令执行失败: ${command}`, (error as Error).message); + process.exit(1); + } +} + +/** + * 异步执行 Shell 命令,返回进程 PID + * @param command 命令名称(例如 "pnpm") + * @param args 命令参数(例如 ["start", "--quickstart"]) + * @param cwd 运行命令的目录 + * @param env 自定义环境变量 + * @returns Promise 返回进程的 PID + */ +export function runCommandAsync( + command: string, + args: string[], + cwd?: string, + env?: Record, +): Promise { + return new Promise((resolve, reject) => { + console.log(`启动进程: ${command} ${args.join(' ')} (目录: ${cwd || '当前目录'})`); + + // 合并 process.env 和用户传入的 env + const child = spawn(command, args, { + cwd, + stdio: 'inherit', + shell: true, + env: { ...process.env, ...env }, // 继承系统环境变量并合并用户传入的变量 + }); + + console.log(`进程 PID: ${child.pid}`); + + child.on('error', (error) => { + console.error(`进程启动失败: ${error.message}`); + reject(error); + }); + + child.on('exit', (code) => { + console.log(`进程退出,代码: ${code}`); + resolve(code ?? -1); + }); + + resolve(child.pid ?? -1); + }); +} + +/** + * 根据端口号查找进程 PID + * @param port 端口号 + * @returns Promise 返回 PID(如果找到) + */ +export function getPidByPort(port: number): Promise { + return new Promise((resolve, reject) => { + const platform = process.platform; + + let command = ''; + if (platform === 'win32') { + command = `netstat -ano | findstr :${port}`; + } else { + command = `lsof -i :${port} | grep LISTEN`; + } + + exec(command, (error, stdout) => { + if (error) { + return resolve(null); + } + + const lines = stdout.split('\n').filter(Boolean); + if (lines.length === 0) { + return resolve(null); // 没有找到进程 + } + + let pid: number | null = null; + if (platform === 'win32') { + const match = lines[0].trim().split(/\s+/).pop(); + if (match) { + pid = parseInt(match, 10); + } + } else { + const match = lines[0].trim().split(/\s+/)[1]; + if (match) { + pid = parseInt(match, 10); + } + } + + resolve(pid || null); + }); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d11c789d4..40fd508a35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4033,6 +4033,25 @@ importers: specifier: ^15.2.0 version: 15.2.0(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + packages/plugin-multi-process-app: + dependencies: + '@tachybase/client': + specifier: workspace:* + version: link:../client + '@tachybase/server': + specifier: workspace:* + version: link:../server + '@tachybase/test': + specifier: workspace:* + version: link:../test + koa-proxies: + specifier: ^0.12.4 + version: 0.12.4(koa@2.15.3) + devDependencies: + koa: + specifier: ^2.15.3 + version: 2.15.3 + packages/plugin-otp: dependencies: '@tachybase/actions': @@ -12060,6 +12079,9 @@ packages: eventemitter3@2.0.3: resolution: {integrity: sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -12802,6 +12824,10 @@ packages: http-cache-semantics@4.1.1: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + http-errors@1.4.0: + resolution: {integrity: sha512-oLjPqve1tuOl5aRhv8GK5eHpqP1C9fb+Ol+XTLjKfLltE44zdDbEdjPSbU7Ch5rSNsVFqZn97SrMmZLdu1/YMw==} + engines: {node: '>= 0.6'} + http-errors@1.6.3: resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==} engines: {node: '>= 0.6'} @@ -12822,6 +12848,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + http-signature@1.2.0: resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} engines: {node: '>=0.8', npm: '>=1.3.7'} @@ -12952,6 +12982,9 @@ packages: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.1: + resolution: {integrity: sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==} + inherits@2.0.3: resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} @@ -13291,6 +13324,9 @@ packages: is-yarn-global@0.3.0: resolution: {integrity: sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==} + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -13531,6 +13567,11 @@ packages: resolution: {integrity: sha512-rm71jaA/P+6HeCpoRhmCv8KVBIi0tfGuO/dMKicbQnQW/YJntJ6MnnspkodoA4QstMVEZArsCphmd0bJEtoMjQ==} engines: {node: '>= 7.6.0'} + koa-proxies@0.12.4: + resolution: {integrity: sha512-xxrEtN0e7s7/gNRoOMUltCbuIaCWqTQUTZNWQqet/8MoxSW0hG422lx2Al9FfYO3nCeA+b5c5/YmILRzavivDA==} + peerDependencies: + koa: '>=2' + koa-router@13.0.1: resolution: {integrity: sha512-4/sijXdSxocIe2wv7RFFSxvo2ic1pDzPSmy11yCGztng1hx408qfw1wVmN3aqhQaU7U6nJ039JKC8ObE73Ohgw==} engines: {node: '>= 18'} @@ -14905,6 +14946,9 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-match@1.2.4: + resolution: {integrity: sha512-UWlehEdqu36jmh4h5CWJ7tARp1OEVKGHKm6+dg9qMq5RKUTV5WJrGgaZ3dN2m7WFAXDbjlHzvJvL/IUpy84Ktw==} + path-name@1.0.0: resolution: {integrity: sha512-/dcAb5vMXH0f51yvMuSUqFpxUcA8JelbRmE5mW/p4CUJxrNgK24IkstnV7ENtg2IDGBOu6izKTG6eilbnbNKWQ==} @@ -14926,6 +14970,9 @@ packages: path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@1.9.0: + resolution: {integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==} + path-to-regexp@3.3.0: resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} @@ -26928,6 +26975,8 @@ snapshots: eventemitter3@2.0.3: {} + eventemitter3@4.0.7: {} + eventemitter3@5.0.1: {} events@3.3.0: {} @@ -27862,6 +27911,11 @@ snapshots: http-cache-semantics@4.1.1: {} + http-errors@1.4.0: + dependencies: + inherits: 2.0.1 + statuses: 1.5.0 + http-errors@1.6.3: dependencies: depd: 1.1.2 @@ -27900,6 +27954,14 @@ snapshots: transitivePeerDependencies: - supports-color + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.9(debug@4.3.7) + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + http-signature@1.2.0: dependencies: assert-plus: 1.0.0 @@ -28031,6 +28093,8 @@ snapshots: once: 1.4.0 wrappy: 1.0.2 + inherits@2.0.1: {} + inherits@2.0.3: {} inherits@2.0.4: {} @@ -28351,6 +28415,8 @@ snapshots: is-yarn-global@0.3.0: {} + isarray@0.0.1: {} + isarray@1.0.0: {} isarray@2.0.5: {} @@ -28642,6 +28708,15 @@ snapshots: transitivePeerDependencies: - supports-color + koa-proxies@0.12.4(koa@2.15.3): + dependencies: + http-proxy: 1.18.1 + koa: 2.15.3 + path-match: 1.2.4 + uuid: 8.3.2 + transitivePeerDependencies: + - debug + koa-router@13.0.1: dependencies: http-errors: 2.0.0 @@ -30309,6 +30384,11 @@ snapshots: path-key@4.0.0: {} + path-match@1.2.4: + dependencies: + http-errors: 1.4.0 + path-to-regexp: 1.9.0 + path-name@1.0.0: {} path-parse@1.0.7: {} @@ -30329,6 +30409,10 @@ snapshots: path-to-regexp@0.1.12: {} + path-to-regexp@1.9.0: + dependencies: + isarray: 0.0.1 + path-to-regexp@3.3.0: {} path-to-regexp@8.0.0: {}