From 40f42ca89b05d6f2ecad21442400de87337894e4 Mon Sep 17 00:00:00 2001 From: xion Date: Fri, 22 Nov 2024 02:13:12 +0800 Subject: [PATCH] feat: add dynamic app --- .gitignore | 4 +- package.json | 3 +- pnpm-lock.yaml | 52 +++++++++++++ rollup.config.mjs | 1 - src/modules/minio.ts | 10 +++ src/routes-simple/code/upload.ts | 44 ++++++----- src/routes/index.ts | 2 + src/routes/micro-app/index.ts | 1 + src/routes/micro-app/list.ts | 91 ++++++++++++++++++++++ src/routes/micro-app/models.ts | 84 ++++++++++++++++++++ src/routes/micro-app/module/install-app.ts | 60 ++++++++++++++ src/routes/micro-app/module/load-app.ts | 29 +++++++ 12 files changed, 358 insertions(+), 23 deletions(-) create mode 100644 src/routes/micro-app/index.ts create mode 100644 src/routes/micro-app/list.ts create mode 100644 src/routes/micro-app/models.ts create mode 100644 src/routes/micro-app/module/install-app.ts create mode 100644 src/routes/micro-app/module/load-app.ts diff --git a/.gitignore b/.gitignore index d021b07..0f0ef3e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ dist app.config.json5 deploy.tar.gz -cache-file \ No newline at end of file +cache-file + +/apps \ No newline at end of file diff --git a/package.json b/package.json index b1bc8c4..e9c5881 100644 --- a/package.json +++ b/package.json @@ -30,13 +30,13 @@ ], "license": "UNLICENSED", "dependencies": { - "@kevisual/auth": "1.0.4", "@abearxiong/use-config": "^0.0.2", "@babel/core": "^7.26.0", "@babel/preset-env": "^7.26.0", "@babel/preset-typescript": "^7.26.0", "@kevisual/ai-graph": "workspace:^", "@kevisual/ai-lang": "workspace:^", + "@kevisual/auth": "1.0.4", "@kevisual/router": "0.0.5-alpha-2", "@supabase/supabase-js": "^2.46.1", "@types/semver": "^7.5.8", @@ -63,6 +63,7 @@ "socket.io": "^4.8.1", "sqlite3": "^5.1.7", "strip-ansi": "^7.1.0", + "tar": "^7.4.3", "uuid": "^11.0.3", "zod": "^3.23.8" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f8a20d..6a1777d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,6 +113,9 @@ importers: strip-ansi: specifier: ^7.1.0 version: 7.1.0 + tar: + specifier: ^7.4.3 + version: 7.4.3 uuid: specifier: ^11.0.3 version: 11.0.3 @@ -1027,6 +1030,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -1774,6 +1781,10 @@ packages: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -2817,6 +2828,10 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} + minizlib@3.0.1: + resolution: {integrity: sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==} + engines: {node: '>= 18'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -2825,6 +2840,11 @@ packages: engines: {node: '>=10'} hasBin: true + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + mock-property@1.1.0: resolution: {integrity: sha512-1/JjbLoGwv87xVsutkX0XJc0M0W4kb40cZl/K41xtTViBOD9JuFPKfyMNTrLJ/ivYAd0aPqu/vduamXO0emTFQ==} engines: {node: '>= 0.4'} @@ -3676,6 +3696,10 @@ packages: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + tar@7.4.3: + resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} + engines: {node: '>=18'} + terser-webpack-plugin@5.3.10: resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} engines: {node: '>= 10.13.0'} @@ -4007,6 +4031,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4854,6 +4882,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -5721,6 +5753,8 @@ snapshots: chownr@2.0.0: {} + chownr@3.0.0: {} + chrome-trace-event@1.0.4: {} clean-stack@2.2.0: @@ -6860,10 +6894,17 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 + minizlib@3.0.1: + dependencies: + minipass: 7.1.2 + rimraf: 6.0.1 + mkdirp-classic@0.5.3: {} mkdirp@1.0.4: {} + mkdirp@3.0.1: {} + mock-property@1.1.0: dependencies: define-data-property: 1.1.4 @@ -7908,6 +7949,15 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 + tar@7.4.3: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.0.1 + mkdirp: 3.0.1 + yallist: 5.0.0 + terser-webpack-plugin@5.3.10(esbuild@0.23.1)(webpack@5.96.1(esbuild@0.23.1)): dependencies: '@jridgewell/trace-mapping': 0.3.25 @@ -8233,6 +8283,8 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/rollup.config.mjs b/rollup.config.mjs index d7e3797..e42e292 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -10,7 +10,6 @@ import esbuild from 'rollup-plugin-esbuild' import alias from '@rollup/plugin-alias' // import ignore from 'rollup-plugin-ignore'; // ignore(['xmlbuilder']), - /** * @type {import('rollup').RollupOptions} */ diff --git a/src/modules/minio.ts b/src/modules/minio.ts index 44084a6..2a1c661 100644 --- a/src/modules/minio.ts +++ b/src/modules/minio.ts @@ -12,3 +12,13 @@ export { bucketName }; if (!minioClient) { throw new Error('Minio client not initialized'); } +// 验证权限 +// (async () => { +// const bucketExists = await minioClient.bucketExists(bucketName); +// if (!bucketExists) { +// await minioClient.makeBucket(bucketName); +// } +// const res = await minioClient.putObject(bucketName, 'private/test/a.b', 'test'); +// console.log('minio putObject', res); + +// })(); diff --git a/src/routes-simple/code/upload.ts b/src/routes-simple/code/upload.ts index 2bbe31c..d6454a6 100644 --- a/src/routes-simple/code/upload.ts +++ b/src/routes-simple/code/upload.ts @@ -8,6 +8,7 @@ import { useFileStore } from '@abearxiong/use-file-store'; import { app, minioClient } from '@/app.ts'; import { bucketName } from '@/modules/minio.ts'; import { getContentType } from '@/utils/get-content-type.ts'; +import { hash } from 'crypto'; const cacheFilePath = useFileStore('cache-file', { needExists: true }); router.post('/api/micro-app/upload', async (req, res) => { @@ -18,10 +19,11 @@ router.post('/api/micro-app/upload', async (req, res) => { // // 使用 formidable 解析 multipart/form-data const form = new IncomingForm({ - multiples: true, // 支持多文件上传 + multiples: false, // 支持多文件上传 uploadDir: cacheFilePath, // 上传文件存储目录 allowEmptyFiles: true, // 允许空 minFileSize: 0, // 最小文件大小 + maxFiles: 1, // 最大文件数量 createDirsFromUploads: false, // 根据上传的文件夹结构创建目录 keepExtensions: true, // 保留文件 hashAlgorithm: 'md5', // 文件哈希算法 @@ -52,31 +54,23 @@ router.post('/api/micro-app/upload', async (req, res) => { fs.unlinkSync(file.filepath); }); }; - let appKey, version; - const { appKey: _appKey, version: _version } = fields; + let appKey, collection; + const { appKey: _appKey, collection: _collecion } = fields; if (Array.isArray(_appKey)) { appKey = _appKey?.[0]; } else { appKey = _appKey; } - if (Array.isArray(_version)) { - version = _version?.[0]; + if (Array.isArray(_collecion)) { + collection = _collecion?.[0]; } else { - version = _version; + collection = _collecion; } - appKey = appKey || 'micro-app'; - // if (!appKey) { - // res.end(error('appKey is required')); - // clearFiles(); - // return; - // } - // if (!version) { - // res.end(error('version is required')); - // clearFiles(); - // return; - // } - console.log('Appkey', appKey, version); + collection = parseIfJson(collection); + appKey = appKey || 'micro-app'; + console.log('Appkey', appKey); + console.log('collection', collection); // 逐个处理每个上传的文件 const uploadedFiles = Array.isArray(files.file) ? files.file : [files.file]; const uploadResults = []; @@ -86,7 +80,7 @@ router.post('/api/micro-app/upload', async (req, res) => { const tempPath = file.filepath; // 文件上传时的临时路径 const relativePath = file.originalFilename; // 保留表单中上传的文件名 (包含文件夹结构) // 比如 child2/b.txt - const minioPath = `/private/${tokenUser.username}/${appKey}/${relativePath}`; + const minioPath = `private/${tokenUser.username}/${appKey}/${relativePath}`; // 上传到 MinIO 并保留文件夹结构 const isHTML = relativePath.endsWith('.html'); await minioClient.fPutObject(bucketName, minioPath, tempPath, { @@ -97,17 +91,20 @@ router.post('/api/micro-app/upload', async (req, res) => { uploadResults.push({ name: relativePath, path: minioPath, + hash: file.hash, + size: file.size, }); fs.unlinkSync(tempPath); // 删除临时文件 } // 受控 const r = await app.call({ path: 'micro-app', - key: 'publish', + key: 'upload', payload: { token: token, data: { appKey, + collection, files: uploadResults, }, }, @@ -122,3 +119,10 @@ router.post('/api/micro-app/upload', async (req, res) => { res.end(JSON.stringify(data)); }); }); +function parseIfJson(collection: any): any { + try { + return JSON.parse(collection); + } catch (e) { + return collection; + } +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 823abe9..6b3125a 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -21,3 +21,5 @@ import './app-manager/index.ts'; import './file/index.ts'; import './packages/index.ts'; + +import './micro-app/index.ts'; diff --git a/src/routes/micro-app/index.ts b/src/routes/micro-app/index.ts new file mode 100644 index 0000000..83ec5cd --- /dev/null +++ b/src/routes/micro-app/index.ts @@ -0,0 +1 @@ +import './list.ts'; diff --git a/src/routes/micro-app/list.ts b/src/routes/micro-app/list.ts new file mode 100644 index 0000000..427b55b --- /dev/null +++ b/src/routes/micro-app/list.ts @@ -0,0 +1,91 @@ +import { app } from '@/app.ts'; +import { MicroAppModel } from './models.ts'; +import { appCheck, installApp } from './module/install-app.ts'; +import { loadApp } from './module/load-app.ts'; + +app + .route({ + path: 'micro-app', + key: 'upload', + middleware: ['auth'], + }) + .define(async (ctx) => { + const { files, collection } = ctx.query?.data; + const { uid, username } = ctx.state.tokenUser; + const file = files[0]; + console.log('File', files); + const { path, name, hash, size } = file; + const microApp = await MicroAppModel.create({ + title: name, + description: '', + type: 'micro-app', + tags: [], + data: { + file: { + path, + size, + name, + hash, + }, + collection, + }, + uid, + share: false, + uname: username, + }); + ctx.body = microApp; + }) + .addTo(app); + +// curl http://localhost:4002/api/router?path=micro-app&key=deploy +app + .route({ + path: 'micro-app', + key: 'deploy', + }) + .define(async (ctx) => { + // const { id, key} = ctx.query?.data; + // const id = '10f03411-85fc-4d37-a4d3-e32b15566a6c'; + // const key = 'envision-cli'; + const id = '7c54a6de-9171-4093-926d-67a035042c6c'; + const key = 'mark'; + if (!id) { + ctx.throw(400, 'Invalid id'); + } + const microApp = await MicroAppModel.findByPk(id); + const { file } = microApp.data || {}; + const path = file?.path; + if (!path) { + ctx.throw(404, 'Invalid path'); + } + console.log('path', path); + const check = await appCheck({ key }); + if (check) { + ctx.throw(400, 'App already exists, please remove it first'); + } + await installApp({ path, key }); + }) + .addTo(app); + +// curl http://localhost:4002/api/router?path=micro-app&key=load +app + .route({ + path: 'micro-app', + key: 'load', + }) + .define(async (ctx) => { + // const { key } = ctx.query?.data; + const key = 'mark'; + try { + const main = await loadApp(key); + if (main?.loadApp) { + await main.loadApp(app); + ctx.body = 'success'; + return; + } + ctx.throw(400, 'Invalid app'); + } catch (e) { + ctx.throw(400, e.message); + } + }) + .addTo(app); diff --git a/src/routes/micro-app/models.ts b/src/routes/micro-app/models.ts new file mode 100644 index 0000000..91d6fd8 --- /dev/null +++ b/src/routes/micro-app/models.ts @@ -0,0 +1,84 @@ +import { sequelize } from '@/modules/sequelize.ts'; +import { DataTypes, Model } from 'sequelize'; + +export type MicroApp = Partial>; + +type MicroAppData = { + file?: { + path: string; + size: number; + hash: string; + }; + data?: any; + collection?: any; +}; +export class MicroAppModel extends Model { + declare id: string; + declare title: string; + declare description: string; + declare type: string; + declare tags: string[]; + declare data: MicroAppData; + declare uid: string; + declare updatedAt: Date; + declare createdAt: Date; + declare source: string; + declare share: boolean; + declare uname: string; +} + +MicroAppModel.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + comment: 'id', + }, + title: { + type: DataTypes.STRING, + defaultValue: '', + }, + description: { + type: DataTypes.STRING, + defaultValue: '', + }, + tags: { + type: DataTypes.JSONB, + defaultValue: [], + }, + type: { + type: DataTypes.STRING, + defaultValue: '', + }, + source: { + type: DataTypes.STRING, + defaultValue: '', + }, + data: { + type: DataTypes.JSONB, + defaultValue: {}, + }, + share: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + uname: { + type: DataTypes.STRING, + defaultValue: '', + }, + uid: { + type: DataTypes.UUID, + allowNull: true, + }, + }, + { + sequelize, + tableName: 'micro_apps', + // paranoid: true, + }, +); + +MicroAppModel.sync({ alter: true, logging: false }).catch((e) => { + console.error('MicroAppModel sync', e); +}); diff --git a/src/routes/micro-app/module/install-app.ts b/src/routes/micro-app/module/install-app.ts new file mode 100644 index 0000000..59fa70e --- /dev/null +++ b/src/routes/micro-app/module/install-app.ts @@ -0,0 +1,60 @@ +import { minioClient } from '@/app.ts'; +import { bucketName } from '@/modules/minio.ts'; +import { checkFileExistsSync } from '@/routes/page/module/cache-file.ts'; +import { useFileStore } from '@abearxiong/use-file-store'; +import fs from 'fs'; +import path from 'path'; +import * as tar from 'tar'; + +const appsPath = useFileStore('apps', { needExists: true }); + +export type InstallAppOpts = { + path?: string; + key?: string; +}; +export const appCheck = async (opts: InstallAppOpts) => { + const { key } = opts; + const directory = path.join(appsPath, key); + if (checkFileExistsSync(directory)) { + return true; + } + return false; +}; +export const installApp = async (opts: InstallAppOpts) => { + const { key } = opts; + const fileStream = await minioClient.getObject(bucketName, opts.path); + const pathName = opts.path.split('/').pop(); + const directory = path.join(appsPath, key); + if (!checkFileExistsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + } + const filePath = path.join(directory, pathName); + + const writeStream = fs.createWriteStream(filePath); + fileStream.pipe(writeStream); + + await new Promise((resolve, reject) => { + writeStream.on('finish', resolve); + writeStream.on('error', reject); + }); + // 解压 tgz文件 + const extractPath = path.join(directory); + await tar.x({ + file: filePath, + cwd: extractPath, + }); + const pkgs = path.join(extractPath, 'package.json'); + if (!checkFileExistsSync(pkgs)) { + throw new Error('Invalid package.json'); + } + const json = fs.readFileSync(pkgs, 'utf-8'); + const pkg = JSON.parse(json); + const { name, version, app } = pkg; + if (!name || !version || !app) { + throw new Error('Invalid package.json'); + } + app.key = key; + fs.writeFileSync(pkgs, JSON.stringify(pkg, null, 2)); + // fs.unlinkSync(filePath); + return { path: filePath }; +}; diff --git a/src/routes/micro-app/module/load-app.ts b/src/routes/micro-app/module/load-app.ts new file mode 100644 index 0000000..411517c --- /dev/null +++ b/src/routes/micro-app/module/load-app.ts @@ -0,0 +1,29 @@ +import { checkFileExistsSync } from '@/routes/page/module/cache-file.ts'; +import { useFileStore } from '@abearxiong/use-file-store'; +import fs from 'fs'; +import path from 'path'; + +const appsPath = useFileStore('apps', { needExists: true }); +export const loadApp = async (key: string) => { + const directory = path.join(appsPath, key); + if (!checkFileExistsSync(directory)) { + throw new Error('app not found'); + } + const pkgs = path.join(directory, 'package.json'); + if (!checkFileExistsSync(pkgs)) { + throw new Error('Invalid package.json'); + } + const json = fs.readFileSync(pkgs, 'utf-8'); + const pkg = JSON.parse(json); + const { name, version, app } = pkg; + if (!name || !version || !app) { + throw new Error('Invalid package.json'); + } + + const mainEntry = path.join(directory, app.entry); + if (!checkFileExistsSync(mainEntry)) { + throw new Error('Invalid main entry'); + } + const main = await import(mainEntry); + return main; +};