feat: application supervisor (#2353)
* chore: skip sync localization-management plugin enable status * chore: unSyncPlugins array * chore: remove install at dev * chore: gateway and supervisor * chore: install command * chore: remove app manager * chore: share plugin * chore: wsserver * chore: websocket server * chore: websocket connection with app status * feat: socket server at gateway * chore: loading message * chore: ready status * chore: handle app error * feat: restart app at supervisor * feat: health check endpoint * chore: test * chore: test * chore: test * chore: test * chore: build * chore: test * chore: build * chore: application ready status * chore: error message * chore: handle application error * chore: handle error in load * chore: report error in websocket * chore: ws error * chore: ws error * chore: typo * chore: switch app ready status at install * chore: test * chore: test * chore: test * chore: test * feat: add WebSocket client * chore: start gateway * chore: start command in application * chore: code * chore: pm command * chore: run start after load sub app * feat: application fsm * refactor: server life cycle (#2402) * refactor: server life cycle * fix: test error * fix: test error * fix: test error * fix: app.start * fix: cronjob stop * fix: db.sync before upgrade * fix: pm.get * fix: test error * fix: test error * fix: test error * test: add test cases * fix: remove PluginType * chore: start application after install * chore: fsm * chore: working status * chore: working command * chore: remove swith app ready status * chore: switch status after working done * chore: ws status * chore: gateway error * chore: test * chore: stopping status * chore: test * chore: test * feat: app proxy * chore: application destory * chore: application message changed with status * chore: test * chore: test timeout * test: remove listener add by plugin * test: remove listener add by plugin * chore: test * feat: app maintaining * fix: add AppMaintainingDialog * feat: off * test: gateway http response * test: gateway with errors * chore: unkown error * chore: websocket message * chore: ws message * chore: code * chore: error format * chore: delay app load * feat: improve code * chore: application initializing status * chore: supervisor with app status * chore: command status response * chore: test * chore: ws message * chore: test * fix: command running message * feat: restart * chore: code * chore: status transition * chore: test * fix: improve code * fix: error * fix: restart * fix: command * chore: reset client app tag when app selector changed * chore: error report * fix: application status * fix: build * chore: disable help command dispatch * chore: test * test: multi apps test * fix: improve code * fix: test * fix: test * fix: multi apps single running * fix: improve code * fix: app status * chore: move plugin static file to gateway * feat: static file handler * chore: test * chore: enable plugins in share collection * chore: gateway serve upload files * fix: improve client * chore: ws nginx config * fix: gateway with naonoid * fix: db sync * fix: loading * fix: ping * fix: locale load * fix: yarn start --quickstart * fix: add debug log * chore: application started event * chore: running working message payload * chore: nginx log * chore: nginxconf * chore: working message log * feat: logs * feat: compression * fix: remove koa-send and koa-static * fix: remove -e * fix: remove nginx * fix: remove -e * fix: __appName * chore: delay install sub application * chore: sync plugin status * fix: boot sub app * fix: main app reload * fix: test * fix: app status * test: field.bind block event loop * feat: newrelic * feat: debug log * chore: upgrade * fix(file-manager): test error * fix: default app selector * fix: reload after maintained * chore: boot sub app * chore: application destroy command * chore: destroy command * chore: clean code * chore: package.json * chore: maintaining message * chore: test * fix: collection.sync * feat: add test cases * chore: application * fix: test error * feat: improve codes and add test cases * fix: test error * fix: pm enable and disable * fix: pm.disable * feat: update docs * chore: update dockerfile --------- Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
0f9d946f09
commit
39ee4b8873
@ -51,7 +51,7 @@ INIT_ROOT_USERNAME=nocobase
|
||||
DEFAULT_STORAGE_TYPE=local
|
||||
|
||||
# LOCAL STORAGE
|
||||
LOCAL_STORAGE_BASE_URL=
|
||||
LOCAL_STORAGE_BASE_URL=/storage/uploads
|
||||
LOCAL_STORAGE_DEST=storage/uploads
|
||||
|
||||
# ALI OSS STORAGE
|
||||
|
6
.github/workflows/nocobase-test-backend.yml
vendored
6
.github/workflows/nocobase-test-backend.yml
vendored
@ -44,7 +44,7 @@ jobs:
|
||||
DB_DIALECT: sqlite
|
||||
DB_STORAGE: /tmp/db.sqlite
|
||||
DB_UNDERSCORED: ${{ matrix.underscored }}
|
||||
timeout-minutes: 35
|
||||
timeout-minutes: 40
|
||||
|
||||
postgres-test:
|
||||
strategy:
|
||||
@ -92,7 +92,7 @@ jobs:
|
||||
DB_UNDERSCORED: ${{ matrix.underscored }}
|
||||
DB_SCHEMA: ${{ matrix.schema }}
|
||||
COLLECTION_MANAGER_SCHEMA: ${{ matrix.collection_schema }}
|
||||
timeout-minutes: 35
|
||||
timeout-minutes: 40
|
||||
|
||||
mysql-test:
|
||||
strategy:
|
||||
@ -128,4 +128,4 @@ jobs:
|
||||
DB_PASSWORD: password
|
||||
DB_DATABASE: nocobase
|
||||
DB_UNDERSCORED: ${{ matrix.underscored }}
|
||||
timeout-minutes: 35
|
||||
timeout-minutes: 40
|
||||
|
@ -40,7 +40,6 @@ RUN cd /app \
|
||||
|
||||
FROM node:16.20-bullseye-slim
|
||||
RUN apt-get update && apt-get install -y nginx
|
||||
|
||||
RUN rm -rf /etc/nginx/sites-enabled/default
|
||||
COPY ./docker/nocobase/nocobase.conf /etc/nginx/sites-enabled/nocobase.conf
|
||||
COPY --from=builder /app/nocobase.tar.gz /app/nocobase.tar.gz
|
||||
|
@ -8,6 +8,7 @@ NocoBase is in early stage of development and is subject to frequent changes, pl
|
||||
|
||||
## Recent major updates
|
||||
|
||||
- [v0.13: New application status flow - 2023/08/24](https://docs.nocobase.com/welcome/release/v13-changelog)
|
||||
- [v0.12: New plugin build tool - 2023/08/01](https://docs.nocobase.com/welcome/release/v12-changelog)
|
||||
- [v0.11: New client application, plugin and router - 2023/07/08](http://docs.nocobase.com/welcome/release/v11-changelog)
|
||||
- [v0.10: Update instructions - 2023/06/23](http://docs.nocobase.com/welcome/release/v10-changelog)
|
||||
|
@ -10,6 +10,8 @@ NocoBase v0.10 has been released, see [v0.10: update instructions](http://docs.n
|
||||
|
||||
## Recent major updates
|
||||
|
||||
- [v0.13: New application status flow - 2023/08/24](https://docs.nocobase.com/welcome/release/v13-changelog)
|
||||
- [v0.12: New plugin build tool - 2023/08/01](https://docs.nocobase.com/welcome/release/v12-changelog)
|
||||
- [v0.11: New client application, plugin and router - 2023/07/08](http://docs.nocobase.com/welcome/release/v11-changelog)
|
||||
- [v0.10: Update instructions - 2023/06/23](http://docs.nocobase.com/welcome/release/v10-changelog)
|
||||
|
||||
|
@ -8,6 +8,7 @@ NocoBase 正处在早期开发阶段,可能变动频繁,请谨慎用于生
|
||||
|
||||
## 最近重要更新
|
||||
|
||||
- [v0.13: 全新的应用状态流转 - 2023/08/24](https://docs-cn.nocobase.com/welcome/release/v13-changelog)
|
||||
- [v0.12: 全新的插件构建工具 - 2023/08/01](https://docs-cn.nocobase.com/welcome/release/v12-changelog)
|
||||
- [v0.11: 全新的客户端 Application、Plugin 和 Router - 2023/07/08](https://docs-cn.nocobase.com/welcome/release/v11-changelog)
|
||||
- [v0.10: Update instructions - 2023/06/23](https://docs-cn.nocobase.com/welcome/release/v10-changelog)
|
||||
|
@ -1,3 +1,17 @@
|
||||
log_format apm '"$time_local" client=$remote_addr '
|
||||
'method=$request_method request="$request" '
|
||||
'request_length=$request_length '
|
||||
'status=$status bytes_sent=$bytes_sent '
|
||||
'body_bytes_sent=$body_bytes_sent '
|
||||
'referer=$http_referer '
|
||||
'user_agent="$http_user_agent" '
|
||||
'upstream_addr=$upstream_addr '
|
||||
'upstream_status=$upstream_status '
|
||||
'request_time=$request_time '
|
||||
'upstream_response_time=$upstream_response_time '
|
||||
'upstream_connect_time=$upstream_connect_time '
|
||||
'upstream_header_time=$upstream_header_time';
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@ -5,6 +19,8 @@ server {
|
||||
index index.html;
|
||||
client_max_body_size 20M;
|
||||
|
||||
access_log /var/log/nginx/nocobase.log apm;
|
||||
|
||||
location /storage/uploads/ {
|
||||
alias /app/nocobase/storage/uploads/;
|
||||
autoindex off;
|
||||
|
@ -1,9 +1,24 @@
|
||||
log_format apm '"$time_local" client=$remote_addr '
|
||||
'method=$request_method request="$request" '
|
||||
'request_length=$request_length '
|
||||
'status=$status bytes_sent=$bytes_sent '
|
||||
'body_bytes_sent=$body_bytes_sent '
|
||||
'referer=$http_referer '
|
||||
'user_agent="$http_user_agent" '
|
||||
'upstream_addr=$upstream_addr '
|
||||
'upstream_status=$upstream_status '
|
||||
'request_time=$request_time '
|
||||
'upstream_response_time=$upstream_response_time '
|
||||
'upstream_connect_time=$upstream_connect_time '
|
||||
'upstream_header_time=$upstream_header_time';
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /app/nocobase/node_modules/@nocobase/app/dist/client;
|
||||
index index.html;
|
||||
client_max_body_size 20M;
|
||||
access_log /var/log/nginx/nocobase.log apm;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
@ -50,4 +65,12 @@ server {
|
||||
proxy_read_timeout 600;
|
||||
send_timeout 600;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:13000/ws;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
|
@ -84,6 +84,7 @@ const sidebar = {
|
||||
},
|
||||
// '/welcome/release/index',
|
||||
// '/welcome/release/v08-changelog',
|
||||
'/welcome/release/v13-changelog',
|
||||
'/welcome/release/v12-changelog',
|
||||
'/welcome/release/v11-changelog',
|
||||
'/welcome/release/v10-changelog',
|
||||
|
@ -1,8 +1,14 @@
|
||||
# Upgrading for `create-nocobase-app`
|
||||
|
||||
## Minor version upgrade
|
||||
<Alert>
|
||||
|
||||
Just execute the `nocobase upgrade` upgrade command
|
||||
After v0.12, apps installed via `create-nocobase-app` no longer have a `packages/app` directory, and code customized in `packages/app` needs to be moved to the custom plugin.
|
||||
|
||||
</Alert>
|
||||
|
||||
## Upgrading
|
||||
|
||||
After v0.12, upgrading the application can be done by running the `yarn nocobase upgrade` command.
|
||||
|
||||
```bash
|
||||
# Switch to the corresponding directory
|
||||
@ -13,63 +19,4 @@ yarn nocobase upgrade
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## Major upgrade
|
||||
|
||||
You can also use this upgrade method if a minor upgrade fails.
|
||||
|
||||
### 1. Create a new NocoBase project
|
||||
|
||||
```bash
|
||||
## SQLite
|
||||
yarn create nocobase-app my-nocobase-app -d sqlite
|
||||
# MySQL
|
||||
yarn create nocobase-app my-nocobase-app -d mysql
|
||||
# PostgreSQL
|
||||
yarn create nocobase-app my-nocobase-app -d postgres
|
||||
```
|
||||
|
||||
### 2. Switching directories
|
||||
|
||||
```bash
|
||||
cd my-nocobase-app
|
||||
```
|
||||
|
||||
### 3. Install dependencies
|
||||
|
||||
📢 This next step may take more than ten minutes due to network environment, system configuration and other factors.
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 4. Modify the .env configuration
|
||||
|
||||
Refer to the old version of .env to modify, the database information needs to be configured correctly. The SQLite database also needs to be copied to the `. /storage/db/` directory.
|
||||
|
||||
### 5. Old code migration (not required)
|
||||
|
||||
Business code refer to the new version of plug-in development tutorial and API reference for modification.
|
||||
|
||||
### 6. Execute the upgrade command
|
||||
|
||||
The code is already the latest version, so you need to skip the code update `--skip-code-update` when upgrading.
|
||||
|
||||
```bash
|
||||
yarn nocobase upgrade --skip-code-update
|
||||
```
|
||||
|
||||
### 7. Start NocoBase
|
||||
|
||||
development environment
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Production environment
|
||||
|
||||
```bash
|
||||
yarn start # Not supported on win platforms yet
|
||||
```
|
||||
|
||||
Note: For production environment, if the code has been modified, you need to execute ``yarn build`` and restart NocoBase.
|
||||
If there are problems with upgrading, you can also [recreate new app](/welcome/getting-started/installation/create-nocobase-app) and refer to the old version of .env to change the environment variables. The database information needs to be configured correctly. When using a SQLite database, you need to copy the database files to the `./storage/db/` directory. Finally, run `yarn nocobase upgrade` to upgrade.
|
||||
|
@ -12,16 +12,12 @@ cd my-nocobase-app
|
||||
git pull
|
||||
```
|
||||
|
||||
## 3. 删除旧依赖文件(非必须)
|
||||
|
||||
v0.10 进行了依赖的重大升级,如果 v0.9 升级 v0.10,需要删掉以下目录之后再升级
|
||||
## 3. Delete cache and dependencies (optional)
|
||||
|
||||
```bash
|
||||
# Remove .umi cache
|
||||
yarn rimraf -rf "./**/{.umi,.umi-production}"
|
||||
# Delete compiled files
|
||||
yarn rimraf -rf "./packages/*/*/{lib,esm,es,dist,node_modules}"
|
||||
# Remove dependencies
|
||||
# delete nocobase cache
|
||||
yarn nocobase clean
|
||||
# delete dependencies
|
||||
yarn rimraf -rf node_modules
|
||||
```
|
||||
|
||||
|
19
docs/en-US/welcome/release/v13-changelog.md
Normal file
19
docs/en-US/welcome/release/v13-changelog.md
Normal file
@ -0,0 +1,19 @@
|
||||
# v0.13: New application status flow
|
||||
|
||||
## New Features
|
||||
|
||||
### Application status flow
|
||||
|
||||
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/57c8e420be0c9c27392d793d5073c060.png" />
|
||||
|
||||
### Demo video
|
||||
|
||||
<video controls width="100%">
|
||||
<source src="https://nocobase.oss-cn-beijing.aliyuncs.com/6430cb4ca6310724a7c25a256bce995f.mp4" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
## Upgrading
|
||||
|
||||
- [Upgrading for Docker compose](/welcome/getting-started/upgrading/docker-compose)
|
||||
- [Upgrading for create-nocobase-app](/welcome/getting-started/upgrading/create-nocobase-app)
|
||||
- [Upgrading for Git source code](/welcome/getting-started/upgrading/git-clone)
|
@ -1,12 +1,12 @@
|
||||
# `create-nocobase-app` 安装的升级
|
||||
|
||||
<Alert>
|
||||
通过 create-nocobase-app 安装的应用不再有 packages/app 目录了,在 packages/app 里自定义的代码,需要移至自定义插件中。
|
||||
v0.12 之后的版本,通过 create-nocobase-app 安装的应用不再有 packages/app 目录了,在 packages/app 里自定义的代码,需要移至自定义插件中。
|
||||
</Alert>
|
||||
|
||||
## 小版本升级
|
||||
## 版本升级
|
||||
|
||||
执行 `nocobase upgrade` 升级命令即可
|
||||
v0.12 之后的版本,应用的升级直接执行 `yarn nocobase upgrade` 升级命令即可
|
||||
|
||||
```bash
|
||||
# 切换到对应的目录
|
||||
@ -17,63 +17,4 @@ yarn nocobase upgrade
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## 大版本升级
|
||||
|
||||
如果小版本升级失效,也可以采用此升级办法。
|
||||
|
||||
### 1. 创建新的 NocoBase 项目
|
||||
|
||||
```bash
|
||||
# SQLite
|
||||
yarn create nocobase-app my-nocobase-app -d sqlite
|
||||
# MySQL
|
||||
yarn create nocobase-app my-nocobase-app -d mysql
|
||||
# PostgreSQL
|
||||
yarn create nocobase-app my-nocobase-app -d postgres
|
||||
```
|
||||
|
||||
### 2. 切换目录
|
||||
|
||||
```bash
|
||||
cd my-nocobase-app
|
||||
```
|
||||
|
||||
### 3. 安装依赖
|
||||
|
||||
📢 由于网络环境、系统配置等因素影响,接下来这一步骤可能需要十几分钟时间。
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 4. 修改 .env 配置
|
||||
|
||||
参考旧版本的 .env 修改,数据库信息需要配置正确。SQLite 数据库也需要将数据库文件复制到 `./storage/db/` 目录。
|
||||
|
||||
### 5. 旧代码迁移(非必须)
|
||||
|
||||
业务代码参考新版插件开发教程和 API 参考进行修改。
|
||||
|
||||
### 6. 执行升级命令
|
||||
|
||||
代码已经是最新版了,所以 upgrade 时需要跳过代码更新 `--skip-code-update`。
|
||||
|
||||
```bash
|
||||
yarn nocobase upgrade --skip-code-update
|
||||
```
|
||||
|
||||
### 7. 启动 NocoBase
|
||||
|
||||
开发环境
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
生产环境
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
注:生产环境,如果代码有修改,需要执行 `yarn build`,再重新启动 NocoBase。
|
||||
如果升级存在问题,也可以[重新创建新应用](/welcome/getting-started/installation/create-nocobase-app),并参考旧版本的 .env 修改环境变量。数据库信息需要配置正确,使用 SQLite 数据库时,需要将数据库文件复制到 `./storage/db/` 目录。最后再执行 `yarn nocobase upgrade` 进行升级。
|
||||
|
@ -12,16 +12,14 @@ cd my-nocobase-app
|
||||
git pull
|
||||
```
|
||||
|
||||
## 3. 删除旧依赖文件(非必须)
|
||||
## 3. 删除缓存和旧依赖(非必须)
|
||||
|
||||
v0.10 进行了依赖的重大升级,如果 v0.9 升级 v0.10,需要删掉以下目录之后再升级
|
||||
如果正常的升级流程失败,可以尝试清空缓存和依赖之后重新下载
|
||||
|
||||
```bash
|
||||
# 删除 .umi 相关缓存
|
||||
yarn rimraf -rf "./**/{.umi,.umi-production}"
|
||||
# 删除编译文件
|
||||
yarn rimraf -rf "./packages/*/*/{lib,esm,es,dist,node_modules}"
|
||||
# 删除全部依赖
|
||||
# 删除 nocobase 缓存
|
||||
yarn nocobase clean
|
||||
# 删除依赖
|
||||
yarn rimraf -rf node_modules
|
||||
```
|
||||
|
||||
|
19
docs/zh-CN/welcome/release/v13-changelog.md
Normal file
19
docs/zh-CN/welcome/release/v13-changelog.md
Normal file
@ -0,0 +1,19 @@
|
||||
# v0.13:全新的应用状态流转
|
||||
|
||||
## 新特性
|
||||
|
||||
### 应用状态流转
|
||||
|
||||
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/57c8e420be0c9c27392d793d5073c060.png" />
|
||||
|
||||
### 演示视频
|
||||
|
||||
<video controls width="100%">
|
||||
<source src="https://nocobase.oss-cn-beijing.aliyuncs.com/6430cb4ca6310724a7c25a256bce995f.mp4" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
## 应用升级
|
||||
|
||||
- [Docker 安装的升级](/welcome/getting-started/upgrading/docker-compose)
|
||||
- [create-nocobase-app 安装的升级](/welcome/getting-started/upgrading/create-nocobase-app)
|
||||
- [Git 源码安装的升级](/welcome/getting-started/upgrading/git-clone)
|
@ -9,8 +9,9 @@ import { MockServer, mockServer } from '@nocobase/test';
|
||||
describe('app test', () => {
|
||||
let app: MockServer;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
app = mockServer();
|
||||
await app.start();
|
||||
});
|
||||
|
||||
test('test1', async () => {
|
||||
|
@ -15,6 +15,7 @@
|
||||
"nocobase": "nocobase",
|
||||
"pm": "nocobase pm",
|
||||
"dev": "nocobase dev",
|
||||
"dev-server": "nocobase dev --server",
|
||||
"start": "nocobase start",
|
||||
"build": "nocobase build",
|
||||
"test": "nocobase test",
|
||||
|
@ -7,6 +7,7 @@ export const app = new Application({
|
||||
baseURL: process.env.API_BASE_URL,
|
||||
},
|
||||
plugins: [NocoBaseClientPresetPlugin],
|
||||
ws: true,
|
||||
devDynamicImport,
|
||||
});
|
||||
|
||||
|
@ -1,10 +1,6 @@
|
||||
import { Application } from '@nocobase/server';
|
||||
import { Gateway } from '@nocobase/server';
|
||||
import config from './config';
|
||||
|
||||
const app = new Application(config);
|
||||
|
||||
if (require.main === module) {
|
||||
app.runAsCLI();
|
||||
}
|
||||
|
||||
export default app;
|
||||
Gateway.getInstance().run({
|
||||
mainAppOptions: config,
|
||||
});
|
||||
|
@ -68,7 +68,7 @@ describe('auth-manager', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
describe('blacklist', () => {
|
||||
|
@ -17,6 +17,7 @@
|
||||
"execa": "^5.1.1",
|
||||
"fast-glob": "^3.2.12",
|
||||
"fs-extra": "^11.1.1",
|
||||
"newrelic": "^10.6.2",
|
||||
"pm2": "^5.2.0",
|
||||
"portfinder": "^1.0.28",
|
||||
"serve": "^13.0.2",
|
||||
|
@ -54,8 +54,6 @@ module.exports = (cli) => {
|
||||
});
|
||||
}
|
||||
|
||||
await runAppCommand('install', ['--silent']);
|
||||
|
||||
if (server || !client) {
|
||||
console.log('starting server', serverPort);
|
||||
|
||||
@ -99,6 +97,7 @@ module.exports = (cli) => {
|
||||
env: {
|
||||
PORT: clientPort,
|
||||
APP_ROOT: `${APP_PACKAGE_ROOT}/client`,
|
||||
WEBSOCKET_URL: process.env.WEBSOCKET_URL || `ws://localhost:${serverPort}/ws`,
|
||||
PROXY_TARGET_URL:
|
||||
process.env.PROXY_TARGET_URL || (serverPort ? `http://127.0.0.1:${serverPort}` : undefined),
|
||||
},
|
||||
|
@ -12,11 +12,10 @@ module.exports = (cli) => {
|
||||
.option('-h, --help')
|
||||
.option('--ts-node-dev')
|
||||
.action((options) => {
|
||||
const { tsNodeDev } = options;
|
||||
if (isDev()) {
|
||||
promptForTs();
|
||||
run(tsNodeDev ? 'ts-node-dev' : 'ts-node', [
|
||||
'-P',
|
||||
run('tsx', [
|
||||
'--tsconfig',
|
||||
'./tsconfig.server.json',
|
||||
'-r',
|
||||
'tsconfig-paths/register',
|
||||
|
@ -41,16 +41,6 @@ module.exports = (cli) => {
|
||||
return;
|
||||
}
|
||||
await postCheck(opts);
|
||||
const restartMark = resolve(process.cwd(), 'storage', 'restart');
|
||||
if (!existsSync(restartMark)) {
|
||||
if (opts.quickstart) {
|
||||
await run('node', [`${APP_PACKAGE_ROOT}/lib/index.js`, 'install', '--ignore-installed']);
|
||||
await run('node', [`${APP_PACKAGE_ROOT}/lib/index.js`, 'upgrade']);
|
||||
}
|
||||
if (opts.dbSync) {
|
||||
await run('node', [`${APP_PACKAGE_ROOT}/lib/index.js`, 'db:sync']);
|
||||
}
|
||||
}
|
||||
if (opts.daemon) {
|
||||
run('pm2', ['start', `${APP_PACKAGE_ROOT}/lib/index.js`, '--', ...process.argv.slice(2)]);
|
||||
} else {
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Field } from '@formily/core';
|
||||
import { Schema, useField, useFieldSchema } from '@formily/react';
|
||||
import { Spin } from 'antd';
|
||||
import React, { createContext, useContext, useEffect } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAPIClient, useRequest } from '../api-client';
|
||||
import { useAppSpin } from '../application/hooks/useAppSpin';
|
||||
import { useBlockRequestContext } from '../block-provider/BlockProvider';
|
||||
import { useCollection } from '../collection-manager';
|
||||
import { useResourceActionContext } from '../collection-manager/ResourceActionProvider';
|
||||
@ -33,6 +33,7 @@ const getRouteUrl = (props) => {
|
||||
export const ACLRolesCheckProvider = (props) => {
|
||||
const route = getRouteUrl(props.children.props);
|
||||
const { setDesignable } = useDesignable();
|
||||
const { render } = useAppSpin();
|
||||
const api = useAPIClient();
|
||||
const result = useRequest<{
|
||||
data: {
|
||||
@ -60,7 +61,7 @@ export const ACLRolesCheckProvider = (props) => {
|
||||
},
|
||||
);
|
||||
if (result.loading) {
|
||||
return <Spin />;
|
||||
return render();
|
||||
}
|
||||
if (result.error) {
|
||||
return <Navigate replace to={'/signin'} />;
|
||||
|
@ -2,6 +2,7 @@ import { APIClient as APIClientSDK } from '@nocobase/sdk';
|
||||
import { Result } from 'ahooks/es/useRequest/src/types';
|
||||
import { notification } from 'antd';
|
||||
import React from 'react';
|
||||
import { Application } from '../application';
|
||||
|
||||
const handleErrorMessage = (error, notification) => {
|
||||
const reader = new FileReader();
|
||||
@ -9,7 +10,7 @@ const handleErrorMessage = (error, notification) => {
|
||||
reader.onload = function () {
|
||||
notification.error({
|
||||
message: JSON.parse(reader.result as string).errors?.map?.((error: any) => {
|
||||
return React.createElement('div', { children: error.message });
|
||||
return React.createElement('div', {}, error.message);
|
||||
}),
|
||||
});
|
||||
};
|
||||
@ -19,6 +20,7 @@ const errorCache = new Map();
|
||||
export class APIClient extends APIClientSDK {
|
||||
services: Record<string, Result<any, any>> = {};
|
||||
silence = false;
|
||||
app: Application;
|
||||
/** 该值会在 AntdAppProvider 中被重新赋值 */
|
||||
notification: any = notification;
|
||||
|
||||
@ -56,6 +58,16 @@ export class APIClient extends APIClientSDK {
|
||||
if (errorCache.size > 10) {
|
||||
errorCache.clear();
|
||||
}
|
||||
const maintaining = !!error?.response?.data?.error?.maintaining;
|
||||
if (this.app.maintaining !== maintaining) {
|
||||
this.app.maintaining = maintaining;
|
||||
}
|
||||
if (this.app.maintaining) {
|
||||
this.app.error = error?.response?.data?.error;
|
||||
return;
|
||||
} else if (this.app.error) {
|
||||
this.app.error = null;
|
||||
}
|
||||
let errs = error?.response?.data?.errors || [{ message: 'Server error' }];
|
||||
errs = errs.filter((error) => {
|
||||
const lastTime = errorCache.get(error.message);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Spin } from 'antd';
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { useRequest } from '../api-client';
|
||||
import { useAppSpin } from '../application/hooks/useAppSpin';
|
||||
|
||||
export const CurrentAppInfoContext = createContext(null);
|
||||
|
||||
@ -16,11 +16,12 @@ export const useCurrentAppInfo = () => {
|
||||
}>(CurrentAppInfoContext);
|
||||
};
|
||||
export const CurrentAppInfoProvider = (props) => {
|
||||
const { render } = useAppSpin();
|
||||
const result = useRequest({
|
||||
url: 'app:getInfo',
|
||||
});
|
||||
if (result.loading) {
|
||||
return <Spin />;
|
||||
return render();
|
||||
}
|
||||
return <CurrentAppInfoContext.Provider value={result.data}>{props.children}</CurrentAppInfoContext.Provider>;
|
||||
};
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { define, observable } from '@formily/reactive';
|
||||
import { APIClientOptions } from '@nocobase/sdk';
|
||||
import { i18n as i18next } from 'i18next';
|
||||
import get from 'lodash/get';
|
||||
@ -12,11 +13,12 @@ import { i18n } from '../i18n';
|
||||
import type { Plugin } from './Plugin';
|
||||
import { PluginManager, PluginType } from './PluginManager';
|
||||
import { ComponentTypeAndString, RouterManager, RouterOptions } from './RouterManager';
|
||||
import { WebSocketClient, WebSocketClientOptions } from './WebSocketClient';
|
||||
import { AppComponent, BlankComponent, defaultAppComponents } from './components';
|
||||
import { compose, normalizeContainer } from './utils';
|
||||
import { defineGlobalDeps } from './utils/globalDeps';
|
||||
import { getRequireJs } from './utils/requirejs';
|
||||
import type { RequireJS } from './utils/requirejs';
|
||||
import { getRequireJs } from './utils/requirejs';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -28,6 +30,7 @@ export type DevDynamicImport = (packageName: string) => Promise<{ default: typeo
|
||||
export type ComponentAndProps<T = any> = [ComponentType, T];
|
||||
export interface ApplicationOptions {
|
||||
apiClient?: APIClientOptions;
|
||||
ws?: WebSocketClientOptions | boolean;
|
||||
i18n?: i18next;
|
||||
providers?: (ComponentType | ComponentAndProps)[];
|
||||
plugins?: PluginType[];
|
||||
@ -42,18 +45,30 @@ export class Application {
|
||||
public router: RouterManager;
|
||||
public scopes: Record<string, any> = {};
|
||||
public i18n: i18next;
|
||||
public ws: WebSocketClient;
|
||||
public apiClient: APIClient;
|
||||
public components: Record<string, ComponentType> = { ...defaultAppComponents };
|
||||
public pm: PluginManager;
|
||||
public devDynamicImport: DevDynamicImport;
|
||||
public requirejs: RequireJS;
|
||||
loading = true;
|
||||
maintained = false;
|
||||
maintaining = false;
|
||||
error = null;
|
||||
|
||||
constructor(protected options: ApplicationOptions = {}) {
|
||||
this.initRequireJs();
|
||||
define(this, {
|
||||
maintained: observable.ref,
|
||||
loading: observable.ref,
|
||||
maintaining: observable.ref,
|
||||
error: observable.ref,
|
||||
});
|
||||
this.devDynamicImport = options.devDynamicImport;
|
||||
this.scopes = merge(this.scopes, options.scopes);
|
||||
this.components = merge(this.components, options.components);
|
||||
this.apiClient = new APIClient(options.apiClient);
|
||||
this.apiClient.app = this;
|
||||
this.i18n = options.i18n || i18n;
|
||||
this.router = new RouterManager({
|
||||
...options.router,
|
||||
@ -63,6 +78,7 @@ export class Application {
|
||||
this.addDefaultProviders();
|
||||
this.addReactRouterComponents();
|
||||
this.addProviders(options.providers || []);
|
||||
this.ws = new WebSocketClient(options.ws);
|
||||
}
|
||||
|
||||
private initRequireJs() {
|
||||
@ -109,7 +125,38 @@ export class Application {
|
||||
}
|
||||
|
||||
async load() {
|
||||
return this.pm.load();
|
||||
this.ws.on('message', (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log(data.payload);
|
||||
const maintaining = data.type === 'maintaining' && data.payload.code !== 'APP_RUNNING';
|
||||
if (maintaining) {
|
||||
this.maintaining = true;
|
||||
this.error = data.payload;
|
||||
} else if (this.maintaining) {
|
||||
// && !this.maintained
|
||||
window.location.reload();
|
||||
return;
|
||||
} else {
|
||||
this.maintaining = false;
|
||||
this.maintained = true;
|
||||
this.error = null;
|
||||
}
|
||||
});
|
||||
this.ws.on('serverDown', () => {
|
||||
this.maintaining = true;
|
||||
});
|
||||
this.ws.connect();
|
||||
try {
|
||||
this.loading = true;
|
||||
await this.pm.load();
|
||||
} catch (error) {
|
||||
this.error = {
|
||||
code: 'LOAD_ERROR',
|
||||
message: error.message,
|
||||
};
|
||||
console.error(error);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
getComponent<T = any>(Component: ComponentTypeAndString<T>, isShowError = true): ComponentType<T> | undefined {
|
||||
|
@ -41,7 +41,7 @@ export class PluginManager {
|
||||
private async initRemotePlugins() {
|
||||
try {
|
||||
const res = await this.app.apiClient.request({ url: 'app:getPlugins' });
|
||||
const pluginList: PluginData[] = res.data?.data || [];
|
||||
const pluginList: PluginData[] = res?.data?.data || [];
|
||||
const plugins = await getPlugins({
|
||||
requirejs: this.app.requirejs,
|
||||
pluginData: pluginList,
|
||||
|
179
packages/core/client/src/application/WebSocketClient.ts
Normal file
179
packages/core/client/src/application/WebSocketClient.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { define, observable } from '@formily/reactive';
|
||||
import { getSubAppName } from '@nocobase/sdk';
|
||||
|
||||
export const getWebSocketURL = () => {
|
||||
if (!process.env.API_BASE_URL) {
|
||||
return;
|
||||
}
|
||||
const subApp = getSubAppName();
|
||||
const queryString = subApp ? `?__appName=${subApp}` : '';
|
||||
console.log(queryString);
|
||||
if (process.env.WEBSOCKET_URL) {
|
||||
return `${process.env.WEBSOCKET_URL}${queryString}`;
|
||||
}
|
||||
try {
|
||||
const url = new URL(process.env.API_BASE_URL);
|
||||
return `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}/ws${queryString}`;
|
||||
} catch (error) {
|
||||
return `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws${queryString}`;
|
||||
}
|
||||
};
|
||||
|
||||
export type WebSocketClientOptions = {
|
||||
reconnectInterval?: number;
|
||||
reconnectAttempts?: number;
|
||||
pingInterval?: number;
|
||||
url?: string;
|
||||
protocols?: string | string[];
|
||||
onServerDown?: any;
|
||||
};
|
||||
|
||||
export class WebSocketClient {
|
||||
protected _ws: WebSocket;
|
||||
protected _reconnectTimes = 0;
|
||||
protected events = [];
|
||||
protected options: WebSocketClientOptions;
|
||||
protected enabled: boolean;
|
||||
connected = false;
|
||||
serverDown = false;
|
||||
lastMessage = {};
|
||||
|
||||
constructor(options: WebSocketClientOptions | boolean) {
|
||||
if (!options) {
|
||||
this.enabled = false;
|
||||
return;
|
||||
}
|
||||
this.options = options === true ? {} : options;
|
||||
this.enabled = true;
|
||||
define(this, {
|
||||
serverDown: observable.ref,
|
||||
connected: observable.ref,
|
||||
lastMessage: observable.ref,
|
||||
});
|
||||
}
|
||||
|
||||
get reconnectAttempts() {
|
||||
return this.options?.reconnectAttempts || 30;
|
||||
}
|
||||
|
||||
get reconnectInterval() {
|
||||
return this.options?.reconnectInterval || 1000;
|
||||
}
|
||||
|
||||
get pingInterval() {
|
||||
return this.options?.pingInterval || 30000;
|
||||
}
|
||||
|
||||
get readyState() {
|
||||
if (!this._ws) {
|
||||
return -1;
|
||||
}
|
||||
return this._ws.readyState;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
if (this._reconnectTimes === 0) {
|
||||
console.log('[nocobase-ws]: connecting...');
|
||||
}
|
||||
if (this._reconnectTimes >= this.reconnectAttempts) {
|
||||
return;
|
||||
}
|
||||
if (this.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
this._reconnectTimes++;
|
||||
const ws = new WebSocket(this.options.url || getWebSocketURL(), this.options.protocols);
|
||||
let pingIntervalTimer: any;
|
||||
ws.onopen = () => {
|
||||
console.log('[nocobase-ws]: connected.');
|
||||
this.serverDown = false;
|
||||
if (this._ws) {
|
||||
this.removeAllListeners();
|
||||
}
|
||||
this._reconnectTimes = 0;
|
||||
this._ws = ws;
|
||||
for (const { type, listener, options } of this.events) {
|
||||
this._ws.addEventListener(type, listener, options);
|
||||
}
|
||||
pingIntervalTimer = setInterval(() => this.send('ping'), this.pingInterval);
|
||||
this.connected = true;
|
||||
};
|
||||
ws.onerror = async () => {
|
||||
// setTimeout(() => this.connect(), this.reconnectInterval);
|
||||
console.log('onerror', this.readyState, this._reconnectTimes);
|
||||
};
|
||||
ws.onclose = async (event) => {
|
||||
setTimeout(() => this.connect(), this.reconnectInterval);
|
||||
console.log('onclose', this.readyState, this._reconnectTimes, this.serverDown);
|
||||
this.connected = false;
|
||||
clearInterval(pingIntervalTimer);
|
||||
if (this._reconnectTimes >= Math.min(this.reconnectAttempts, 5)) {
|
||||
this.serverDown = true;
|
||||
this.emit('serverDown', event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
this._reconnectTimes = 0;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this._ws) {
|
||||
return;
|
||||
}
|
||||
this._reconnectTimes = this.reconnectAttempts;
|
||||
return this._ws.close();
|
||||
}
|
||||
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
|
||||
if (!this._ws) {
|
||||
return;
|
||||
}
|
||||
return this._ws.send(data);
|
||||
}
|
||||
|
||||
on(type: string, listener: any, options?: boolean | AddEventListenerOptions) {
|
||||
this.events.push({ type, listener, options });
|
||||
if (!this._ws) {
|
||||
return;
|
||||
}
|
||||
this._ws.addEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
emit(type: string, args: any) {
|
||||
for (const event of this.events) {
|
||||
if (event.type === type) {
|
||||
event.listener(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
off(type: string, listener: any, options?: boolean | EventListenerOptions) {
|
||||
let index = 0;
|
||||
for (const event of this.events) {
|
||||
if (event.type === type && event.listener === listener) {
|
||||
this.events.splice(index, 1);
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
if (!this._ws) {
|
||||
return;
|
||||
}
|
||||
this._ws.removeEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
removeAllListeners() {
|
||||
if (!this._ws) {
|
||||
return;
|
||||
}
|
||||
for (const { type, listener, options } of this.events) {
|
||||
this._ws.removeEventListener(type, listener, options);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,25 +1,33 @@
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import { observer } from '@formily/reactive-react';
|
||||
import React, { FC, useCallback, useEffect } from 'react';
|
||||
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
|
||||
import type { Application } from '../Application';
|
||||
import { ApplicationContext } from '../context';
|
||||
import { useAppPluginLoad } from '../hooks';
|
||||
|
||||
export interface AppComponentProps {
|
||||
app: Application;
|
||||
}
|
||||
|
||||
export const AppComponent: FC<AppComponentProps> = (props) => {
|
||||
export const AppComponent: FC<AppComponentProps> = observer((props) => {
|
||||
const { app } = props;
|
||||
const { loading, error } = useAppPluginLoad(app);
|
||||
const handleErrors = useCallback((error: Error, info: { componentStack: string }) => {
|
||||
console.error(error, info);
|
||||
}, []);
|
||||
if (loading) return app.renderComponent('AppSpin', { app });
|
||||
if (error) return app.renderComponent('AppError', { app, error });
|
||||
|
||||
useEffect(() => {
|
||||
app.load();
|
||||
}, [app]);
|
||||
|
||||
if (app.loading) return app.renderComponent('AppSpin', { app });
|
||||
if (!app.maintained && app.maintaining) return app.renderComponent('AppMaintaining', { app });
|
||||
if (app.error?.code === 'LOAD_ERROR') return app.renderComponent('AppError', { app });
|
||||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={app.getComponent<FallbackProps>('ErrorFallback')} onError={handleErrors}>
|
||||
<ApplicationContext.Provider value={app}>{app.renderComponent('AppMain')}</ApplicationContext.Provider>
|
||||
<ApplicationContext.Provider value={app}>
|
||||
{app.maintained && app.maintaining && app.renderComponent('AppMaintainingDialog', { app })}
|
||||
{app.renderComponent('AppMain')}
|
||||
</ApplicationContext.Provider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { createContext } from 'react';
|
||||
import type { Application } from './Application';
|
||||
import { Application } from './Application';
|
||||
|
||||
export const ApplicationContext = createContext<Application>(null);
|
||||
|
@ -5,6 +5,9 @@ export function useAppPluginLoad(app: Application) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
useEffect(() => {
|
||||
app.ws.on('message', (event) => {
|
||||
console.log(event.data);
|
||||
});
|
||||
async function run() {
|
||||
try {
|
||||
await app.load();
|
||||
|
10
packages/core/client/src/application/hooks/useAppSpin.ts
Normal file
10
packages/core/client/src/application/hooks/useAppSpin.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Spin } from 'antd';
|
||||
import React from 'react';
|
||||
import { useApp } from './useApp';
|
||||
|
||||
export const useAppSpin = () => {
|
||||
const app = useApp();
|
||||
return {
|
||||
render: () => (app ? app?.renderComponent?.('AppSpin') : React.createElement(Spin)),
|
||||
};
|
||||
};
|
@ -1,7 +1,7 @@
|
||||
import { Spin } from 'antd';
|
||||
import React, { createContext, useContext, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAPIClient, useRequest } from '../api-client';
|
||||
import { useAppSpin } from '../application/hooks/useAppSpin';
|
||||
|
||||
export interface CollectionHistoryContextValue {
|
||||
historyCollections: any[];
|
||||
@ -41,6 +41,7 @@ export const CollectionHistoryProvider: React.FC = (props) => {
|
||||
|
||||
const isAdminPage = location.pathname.startsWith('/admin');
|
||||
const token = api.auth.getToken() || '';
|
||||
const { render } = useAppSpin();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdminPage && token) {
|
||||
@ -56,7 +57,7 @@ export const CollectionHistoryProvider: React.FC = (props) => {
|
||||
};
|
||||
|
||||
if (service.loading) {
|
||||
return <Spin />;
|
||||
return render();
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { Spin } from 'antd';
|
||||
import { keyBy } from 'lodash';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAPIClient, useRequest } from '../api-client';
|
||||
import { useAppSpin } from '../application/hooks/useAppSpin';
|
||||
import { templateOptions } from '../collection-manager/Configuration/templates';
|
||||
import { useCollectionHistory } from './CollectionHistoryProvider';
|
||||
import { CollectionManagerSchemaComponentProvider } from './CollectionManagerSchemaComponentProvider';
|
||||
@ -36,6 +36,7 @@ export const RemoteCollectionManagerProvider = (props: any) => {
|
||||
const api = useAPIClient();
|
||||
const [contentLoading, setContentLoading] = useState(false);
|
||||
const { refreshCH } = useCollectionHistory();
|
||||
const { render } = useAppSpin();
|
||||
const options = {
|
||||
resource: 'collections',
|
||||
action: 'list',
|
||||
@ -63,7 +64,7 @@ export const RemoteCollectionManagerProvider = (props: any) => {
|
||||
}>(coptions);
|
||||
|
||||
if (service.loading) {
|
||||
return <Spin />;
|
||||
return render();
|
||||
}
|
||||
const refreshCM = async (opts) => {
|
||||
if (opts?.reload) {
|
||||
|
@ -769,7 +769,7 @@ export default {
|
||||
'Reload application': '重载应用',
|
||||
'The application is reloading, please do not close the page.': '应用正在重新加载,请勿关闭页面。',
|
||||
'Application reloading': '应用重新加载中',
|
||||
'Reboot application': '重启应用',
|
||||
'Restart application': '重启应用',
|
||||
"Allows to clear cache, reboot application": "允许清除缓存,重启应用",
|
||||
'The will interrupt service, it may take a few seconds to restart. Are you sure to continue?': '重启将会中断当前服务,这个过程可能需要一点时间,确定要继续吗?',
|
||||
'Reboot': '重启',
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { css } from '@emotion/css';
|
||||
import { Button, Result, Spin } from 'antd';
|
||||
import { observer } from '@formily/reactive-react';
|
||||
import { Button, Modal, Result, Spin } from 'antd';
|
||||
import React, { FC } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { ACLPlugin } from '../acl';
|
||||
@ -21,9 +23,13 @@ import { SystemSettingsPlugin } from '../system-settings';
|
||||
import { CurrentUserProvider, CurrentUserSettingsMenuProvider } from '../user';
|
||||
import { LocalePlugin } from './plugins/LocalePlugin';
|
||||
|
||||
const AppSpin = Spin;
|
||||
const AppSpin = () => {
|
||||
return (
|
||||
<Spin style={{ position: 'fixed', top: '50%', left: '50%', fontSize: 72, transform: 'translate(-50%, -50%)' }} />
|
||||
);
|
||||
};
|
||||
|
||||
const AppError: FC<{ app: Application; error: Error }> = ({ app, error }) => (
|
||||
const AppError: FC<{ app: Application }> = observer(({ app }) => (
|
||||
<div>
|
||||
<Result
|
||||
className={css`
|
||||
@ -34,7 +40,7 @@ const AppError: FC<{ app: Application; error: Error }> = ({ app, error }) => (
|
||||
`}
|
||||
status="error"
|
||||
title={app.i18n.t('Failed to load plugin')}
|
||||
subTitle={app.i18n.t(error?.message)}
|
||||
subTitle={app.i18n.t(app.error?.message)}
|
||||
extra={[
|
||||
<Button type="primary" key="try" onClick={() => window.location.reload()}>
|
||||
{app.i18n.t('Try again')}
|
||||
@ -42,13 +48,142 @@ const AppError: FC<{ app: Application; error: Error }> = ({ app, error }) => (
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
const getProps = (app: Application) => {
|
||||
if (app.ws.serverDown) {
|
||||
return {
|
||||
status: 'error',
|
||||
title: 'App error',
|
||||
subTitle: 'The server is down',
|
||||
};
|
||||
}
|
||||
|
||||
if (!app.error) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (app.error.code === 'APP_NOT_FOUND') {
|
||||
return {
|
||||
status: 'warning',
|
||||
title: 'App not found',
|
||||
subTitle: app.error?.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (app.error.code === 'APP_INITIALIZING') {
|
||||
return {
|
||||
status: 'info',
|
||||
icon: <LoadingOutlined />,
|
||||
title: 'App initializing',
|
||||
subTitle: app.error?.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (app.error.code === 'APP_INITIALIZED') {
|
||||
return {
|
||||
status: 'warning',
|
||||
title: 'App initialized',
|
||||
subTitle: app.error?.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (app.error.code === 'APP_ERROR') {
|
||||
return {
|
||||
status: 'error',
|
||||
title: 'App error',
|
||||
subTitle: app.error?.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (app.error.code === 'APP_NOT_INSTALLED_ERROR') {
|
||||
return {
|
||||
status: 'warning',
|
||||
title: 'App not installed',
|
||||
subTitle: app.error?.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (app.error.code === 'APP_STOPPED') {
|
||||
return {
|
||||
status: 'warning',
|
||||
title: 'App stopped',
|
||||
subTitle: app.error?.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (app.error.code === 'APP_COMMANDING') {
|
||||
const props = {
|
||||
status: 'info',
|
||||
icon: <LoadingOutlined />,
|
||||
title: app.error?.command?.name,
|
||||
subTitle: app.error?.message,
|
||||
};
|
||||
const commands = {
|
||||
start: {
|
||||
title: 'App starting',
|
||||
},
|
||||
restart: {
|
||||
title: 'App restarting',
|
||||
},
|
||||
install: {
|
||||
title: 'App installing',
|
||||
},
|
||||
upgrade: {
|
||||
title: 'App upgrading',
|
||||
},
|
||||
'pm.enable': {
|
||||
title: 'Enabling plugin',
|
||||
},
|
||||
'pm.disable': {
|
||||
title: 'Disabling plugin',
|
||||
},
|
||||
};
|
||||
return { ...props, ...commands[app.error?.command?.name] };
|
||||
}
|
||||
};
|
||||
|
||||
const AppMaintaining: FC<{ app: Application; error: Error }> = observer(({ app }) => {
|
||||
const { icon, status, title, subTitle } = getProps(app);
|
||||
return (
|
||||
<div>
|
||||
<Result
|
||||
className={css`
|
||||
top: 50%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
transform: translate(0, -50%);
|
||||
`}
|
||||
icon={icon}
|
||||
status={status}
|
||||
title={app.i18n.t(title)}
|
||||
subTitle={app.i18n.t(subTitle)}
|
||||
// extra={[
|
||||
// <Button type="primary" key="try" onClick={() => window.location.reload()}>
|
||||
// {app.i18n.t('Try again')}
|
||||
// </Button>,
|
||||
// ]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const AppMaintainingDialog: FC<{ app: Application; error: Error }> = observer(({ app }) => {
|
||||
const { icon, status, title, subTitle } = getProps(app);
|
||||
return (
|
||||
<Modal open={true} footer={null} closable={false}>
|
||||
<Result icon={icon} status={status} title={app.i18n.t(title)} subTitle={app.i18n.t(subTitle)} />
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export class NocoBaseBuildInPlugin extends Plugin {
|
||||
async afterAdd() {
|
||||
this.app.addComponents({
|
||||
AppSpin,
|
||||
AppError,
|
||||
AppMaintaining,
|
||||
AppMaintainingDialog,
|
||||
});
|
||||
await this.addPlugins();
|
||||
}
|
||||
|
@ -10,12 +10,13 @@ export class LocalePlugin extends Plugin {
|
||||
const api = this.app.apiClient;
|
||||
const locale = api.auth.locale;
|
||||
try {
|
||||
const { data } = await api.request({
|
||||
const res = await api.request({
|
||||
url: 'app:getLang',
|
||||
params: {
|
||||
locale,
|
||||
},
|
||||
});
|
||||
const data = res?.data;
|
||||
this.locales = data?.data || {};
|
||||
this.app.use(ConfigProvider, { locale: this.locales.antd, popupMatchSelectWidth: false });
|
||||
if (data?.data?.lang && !locale) {
|
||||
|
@ -247,7 +247,7 @@ export const PluginCard = (props: { data: IPluginData }) => {
|
||||
url: `pm:remove/${name}`,
|
||||
});
|
||||
message.success(t('插件删除成功'));
|
||||
window.location.reload();
|
||||
// window.location.reload();
|
||||
}}
|
||||
onCancel={(e) => e.stopPropagation()}
|
||||
okText={t('Yes')}
|
||||
@ -260,19 +260,19 @@ export const PluginCard = (props: { data: IPluginData }) => {
|
||||
size={'small'}
|
||||
onChange={async (checked, e) => {
|
||||
e.stopPropagation();
|
||||
modal.warning({
|
||||
title: checked ? t('Plugin starting') : t('Plugin stopping'),
|
||||
content: t('The application is reloading, please do not close the page.'),
|
||||
okButtonProps: {
|
||||
style: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
// modal.warning({
|
||||
// title: checked ? t('Plugin starting') : t('Plugin stopping'),
|
||||
// content: t('The application is reloading, please do not close the page.'),
|
||||
// okButtonProps: {
|
||||
// style: {
|
||||
// display: 'none',
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
await api.request({
|
||||
url: `pm:${checked ? 'enable' : 'disable'}/${name}`,
|
||||
});
|
||||
window.location.reload();
|
||||
// window.location.reload();
|
||||
// message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
|
||||
}}
|
||||
defaultChecked={enabled}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Layout, Spin } from 'antd';
|
||||
import { Layout } from 'antd';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Outlet, useMatch, useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
@ -22,6 +22,7 @@ import {
|
||||
useToken,
|
||||
} from '../../../';
|
||||
import { Plugin } from '../../../application/Plugin';
|
||||
import { useAppSpin } from '../../../application/hooks/useAppSpin';
|
||||
import { useCollectionManager } from '../../../collection-manager';
|
||||
|
||||
const filterByACL = (schema, options) => {
|
||||
@ -74,7 +75,7 @@ const MenuEditor = (props) => {
|
||||
setCurrent(schema);
|
||||
navigate(`/admin/${schema['x-uid']}`);
|
||||
};
|
||||
|
||||
const { render } = useAppSpin();
|
||||
const adminSchemaUid = useAdminSchemaUid();
|
||||
const { data, loading } = useRequest<{
|
||||
data: any;
|
||||
@ -140,7 +141,7 @@ const MenuEditor = (props) => {
|
||||
return s;
|
||||
}, [data?.data]);
|
||||
if (loading) {
|
||||
return <Spin />;
|
||||
return render();
|
||||
}
|
||||
return (
|
||||
<SchemaIdContext.Provider value={defaultSelectedUid}>
|
||||
@ -155,6 +156,7 @@ export const InternalAdminLayout = (props: any) => {
|
||||
const { service } = useCollectionManager();
|
||||
const params = useParams<any>();
|
||||
const { token } = useToken();
|
||||
const { render } = useAppSpin();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
@ -299,7 +301,7 @@ export const InternalAdminLayout = (props: any) => {
|
||||
pointer-events: none;
|
||||
`}
|
||||
></header>
|
||||
{service.contentLoading ? <Spin /> : <Outlet />}
|
||||
{service.contentLoading ? render() : <Outlet />}
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
);
|
||||
|
@ -2,13 +2,14 @@ import { PlusOutlined } from '@ant-design/icons';
|
||||
import { PageHeader as AntdPageHeader } from '@ant-design/pro-layout';
|
||||
import { FormLayout } from '@formily/antd-v5';
|
||||
import { Schema, SchemaOptionsContext, useFieldSchema } from '@formily/react';
|
||||
import { Button, Spin, Tabs } from 'antd';
|
||||
import { Button, Tabs } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { FormDialog } from '..';
|
||||
import { useAppSpin } from '../../../application/hooks/useAppSpin';
|
||||
import { useDocumentTitle } from '../../../document-title';
|
||||
import { FilterBlockProvider } from '../../../filter-provider/FilterProvider';
|
||||
import { useGlobalTheme } from '../../../global-theme';
|
||||
@ -194,9 +195,10 @@ function PageContent(
|
||||
props: any,
|
||||
): React.ReactNode {
|
||||
const { token } = useToken();
|
||||
const { render } = useAppSpin();
|
||||
|
||||
if (loading) {
|
||||
return <Spin />;
|
||||
return render();
|
||||
}
|
||||
|
||||
return !disablePageHeader && enablePageTabs ? (
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { ISchema, useFieldSchema } from '@formily/react';
|
||||
import { uid } from '@formily/shared';
|
||||
import { Spin } from 'antd';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
|
||||
import React, { ReactNode, createContext, useContext, useMemo } from 'react';
|
||||
import { useAPIClient, useRequest } from '../api-client';
|
||||
import { Plugin } from '../application/Plugin';
|
||||
import { useAppSpin } from '../application/hooks/useAppSpin';
|
||||
import { useCollectionManager } from '../collection-manager';
|
||||
import { BlockTemplate } from './BlockTemplate';
|
||||
|
||||
@ -116,6 +116,7 @@ export const useSchemaTemplateManager = () => {
|
||||
|
||||
export const RemoteSchemaTemplateManagerProvider: React.FC<{ children?: ReactNode }> = (props) => {
|
||||
const api = useAPIClient();
|
||||
const { render } = useAppSpin();
|
||||
const options = {
|
||||
resource: 'uiSchemaTemplates',
|
||||
action: 'list',
|
||||
@ -128,7 +129,7 @@ export const RemoteSchemaTemplateManagerProvider: React.FC<{ children?: ReactNod
|
||||
data: any[];
|
||||
}>(options);
|
||||
if (service.loading) {
|
||||
return <Spin />;
|
||||
return render();
|
||||
}
|
||||
return (
|
||||
<SchemaTemplateManagerProvider
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Result } from 'ahooks/es/useRequest/src/types';
|
||||
import { Spin } from 'antd';
|
||||
import React, { createContext, ReactNode, useContext } from 'react';
|
||||
import { useRequest } from '..';
|
||||
import { useRequest } from '../api-client';
|
||||
import { useAppSpin } from '../application/hooks/useAppSpin';
|
||||
|
||||
export const SystemSettingsContext = createContext<Result<any, any>>(null);
|
||||
|
||||
@ -10,11 +10,12 @@ export const useSystemSettings = () => {
|
||||
};
|
||||
|
||||
export const SystemSettingsProvider: React.FC<{ children?: ReactNode }> = (props) => {
|
||||
const { render } = useAppSpin();
|
||||
const result = useRequest({
|
||||
url: 'systemSettings:get/1?appends=logo',
|
||||
});
|
||||
if (result.loading) {
|
||||
return <Spin />;
|
||||
return render();
|
||||
}
|
||||
return <SystemSettingsContext.Provider value={result}>{props.children}</SystemSettingsContext.Provider>;
|
||||
};
|
||||
|
@ -78,19 +78,17 @@ export const SettingsMenu: React.FC<{
|
||||
},
|
||||
{
|
||||
key: 'reboot',
|
||||
label: t('Reboot application'),
|
||||
label: t('Restart application'),
|
||||
onClick: async () => {
|
||||
modal.confirm({
|
||||
title: t('Reboot application'),
|
||||
content: t('The will interrupt service, it may take a few seconds to restart. Are you sure to continue?'),
|
||||
okText: t('Reboot'),
|
||||
title: t('Restart application'),
|
||||
// content: t('The will interrupt service, it may take a few seconds to restart. Are you sure to continue?'),
|
||||
okText: t('Restart'),
|
||||
okButtonProps: {
|
||||
danger: true,
|
||||
},
|
||||
onOk: async () => {
|
||||
await api.resource('app').reboot();
|
||||
await check();
|
||||
window.location.reload();
|
||||
await api.resource('app').restart();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { Spin } from 'antd';
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useACLRoleContext } from '../acl';
|
||||
import { ReturnTypeOfUseRequest, useRequest } from '../api-client';
|
||||
import { useAppSpin } from '../application/hooks/useAppSpin';
|
||||
import { useCompile } from '../schema-component';
|
||||
|
||||
export const CurrentUserContext = createContext<ReturnTypeOfUseRequest>(null);
|
||||
@ -29,11 +29,12 @@ export const useCurrentRoles = () => {
|
||||
};
|
||||
|
||||
export const CurrentUserProvider = (props) => {
|
||||
const { render } = useAppSpin();
|
||||
const result = useRequest<any>({
|
||||
url: 'auth:check',
|
||||
});
|
||||
if (result.loading) {
|
||||
return <Spin />;
|
||||
return render();
|
||||
}
|
||||
return <CurrentUserContext.Provider value={result}>{props.children}</CurrentUserContext.Provider>;
|
||||
};
|
||||
|
@ -35,6 +35,7 @@ function getUmiConfig() {
|
||||
'process.env.API_BASE_URL': API_BASE_URL || API_BASE_PATH,
|
||||
'process.env.APP_ENV': process.env.APP_ENV,
|
||||
'process.env.VERSION': packageJson.version,
|
||||
'process.env.WEBSOCKET_URL': process.env.WEBSOCKET_URL,
|
||||
},
|
||||
// only proxy when using `umi dev`
|
||||
// if the assets are built, will not proxy
|
||||
|
@ -1,9 +1,9 @@
|
||||
const getSubAppName = () => {
|
||||
const match = window.location.pathname.match(/^\/apps\/([^/]*)\//);
|
||||
const match = window.location.pathname.match(/^\/apps\/([^/]*)\/?/);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
return match[1].toUpperCase();
|
||||
return match[1];
|
||||
};
|
||||
|
||||
export default getSubAppName;
|
||||
|
@ -36,10 +36,15 @@
|
||||
"lodash": "^4.17.21",
|
||||
"multer": "^1.4.2",
|
||||
"semver": "^7.3.7",
|
||||
"xpipe": "^1.0.5"
|
||||
"serve-handler": "^6.1.5",
|
||||
"xpipe": "^1.0.5",
|
||||
"ws": "^8.13.0",
|
||||
"nanoid": "3.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/semver": "^7.3.9"
|
||||
"@types/semver": "^7.3.9",
|
||||
"@types/ws": "^8.5.5",
|
||||
"@types/serve-handler": "^6.1.1"
|
||||
},
|
||||
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
|
||||
}
|
||||
|
157
packages/core/server/src/__tests__/app-life-cycle.test.ts
Normal file
157
packages/core/server/src/__tests__/app-life-cycle.test.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import Application, { ApplicationOptions } from '../application';
|
||||
import { createAppProxy } from '../helper';
|
||||
import Plugin from '../plugin';
|
||||
import { AddPresetError } from '../plugin-manager';
|
||||
|
||||
const mockServer = (options?: ApplicationOptions) => {
|
||||
return new Application({
|
||||
database: {
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
describe('application life cycle', () => {
|
||||
let app: Application;
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) {
|
||||
await app.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
describe('reInitEvents', () => {
|
||||
it('should be called', async () => {
|
||||
app = mockServer();
|
||||
const loadFn = jest.fn();
|
||||
app.on('event1', () => {
|
||||
loadFn();
|
||||
});
|
||||
app.reInitEvents();
|
||||
app.emit('event1');
|
||||
expect(loadFn).toBeCalled();
|
||||
expect(loadFn).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not be called', async () => {
|
||||
app = createAppProxy(mockServer());
|
||||
const loadFn = jest.fn();
|
||||
app.on('event1', () => {
|
||||
loadFn();
|
||||
});
|
||||
app.reInitEvents();
|
||||
app.emit('event1');
|
||||
expect(loadFn).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not be called', async () => {
|
||||
app = createAppProxy(mockServer());
|
||||
const loadFn = jest.fn();
|
||||
|
||||
app.on('event1', () => {
|
||||
loadFn();
|
||||
});
|
||||
|
||||
app.on('event1', () => {
|
||||
loadFn();
|
||||
});
|
||||
|
||||
expect(app.listenerCount('event1')).toBe(2);
|
||||
app.reInitEvents();
|
||||
expect(app.listenerCount('event1')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load', () => {
|
||||
it('should be called', async () => {
|
||||
app = mockServer();
|
||||
const loadFn = jest.fn();
|
||||
app.on('beforeLoad', () => {
|
||||
loadFn();
|
||||
});
|
||||
|
||||
await app.load();
|
||||
await app.load();
|
||||
expect(loadFn).toBeCalled();
|
||||
expect(loadFn).toBeCalledTimes(1);
|
||||
await app.reload();
|
||||
await app.reload();
|
||||
|
||||
expect(app.listenerCount('beforeLoad')).toBe(1);
|
||||
|
||||
expect(loadFn).toBeCalledTimes(3);
|
||||
});
|
||||
it('should be called', async () => {
|
||||
app = mockServer();
|
||||
const loadFn = jest.fn();
|
||||
class Plugin1 extends Plugin {
|
||||
afterAdd() {
|
||||
this.app.on('beforeLoad', () => {
|
||||
loadFn();
|
||||
});
|
||||
}
|
||||
}
|
||||
app.pm.addPreset(Plugin1);
|
||||
await app.load();
|
||||
await app.load();
|
||||
expect(loadFn).toBeCalled();
|
||||
expect(loadFn).toBeCalledTimes(1);
|
||||
await app.reload();
|
||||
await app.reload();
|
||||
expect(loadFn).toBeCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addPreset', () => {
|
||||
it('should remove listener that add by plugin', async () => {
|
||||
function testFunc() {}
|
||||
class Plugin1 extends Plugin {
|
||||
async beforeLoad() {
|
||||
this.app.on('afterLoad', testFunc);
|
||||
}
|
||||
}
|
||||
|
||||
app = mockServer({
|
||||
plugins: [Plugin1],
|
||||
});
|
||||
|
||||
await app.load();
|
||||
expect(app.listeners('afterLoad').filter((fn) => fn.name == testFunc.name)).toHaveLength(1);
|
||||
await app.reload();
|
||||
expect(app.listeners('afterLoad').filter((fn) => fn.name == testFunc.name)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should init after app.load()', async () => {
|
||||
class Plugin1 extends Plugin {}
|
||||
app = mockServer({
|
||||
plugins: [Plugin1],
|
||||
});
|
||||
expect(app.pm.has(Plugin1)).toBeFalsy();
|
||||
await app.load();
|
||||
expect(app.pm.has(Plugin1)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should init after app.load()', async () => {
|
||||
class Plugin1 extends Plugin {}
|
||||
class Plugin2 extends Plugin {}
|
||||
app = mockServer({
|
||||
plugins: [Plugin1],
|
||||
});
|
||||
app.pm.addPreset(Plugin2);
|
||||
expect(app.pm.has(Plugin1)).toBeFalsy();
|
||||
expect(app.pm.has(Plugin2)).toBeFalsy();
|
||||
await app.load();
|
||||
expect(app.pm.has(Plugin1)).toBeTruthy();
|
||||
expect(app.pm.has(Plugin2)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should throw error', async () => {
|
||||
class Plugin1 extends Plugin {}
|
||||
app = mockServer();
|
||||
await app.load();
|
||||
expect(() => app.pm.addPreset(Plugin1)).toThrow(AddPresetError);
|
||||
});
|
||||
});
|
||||
});
|
29
packages/core/server/src/__tests__/app-supervisor.test.ts
Normal file
29
packages/core/server/src/__tests__/app-supervisor.test.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { AppSupervisor } from '../app-supervisor';
|
||||
|
||||
describe('App Supervisor', () => {
|
||||
let appSupervisor: AppSupervisor;
|
||||
|
||||
beforeEach(() => {
|
||||
appSupervisor = AppSupervisor.getInstance();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await appSupervisor.destroy();
|
||||
});
|
||||
|
||||
it('should get application initializing status', async () => {
|
||||
expect(appSupervisor.getAppStatus('test')).toBe(undefined);
|
||||
|
||||
appSupervisor.setAppBootstrapper(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
});
|
||||
|
||||
appSupervisor.getApp('test');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
expect(appSupervisor.getAppStatus('test')).toBe('initializing');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
expect(appSupervisor.getAppStatus('test')).toBe('not_found');
|
||||
});
|
||||
});
|
106
packages/core/server/src/__tests__/app.test.ts
Normal file
106
packages/core/server/src/__tests__/app.test.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { DataTypes, mockDatabase } from '@nocobase/database';
|
||||
import Application, { ApplicationOptions } from '../application';
|
||||
import Plugin from '../plugin';
|
||||
|
||||
const mockServer = (options?: ApplicationOptions) => {
|
||||
return new Application({
|
||||
database: mockDatabase(),
|
||||
acl: false,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
describe('app destroy', () => {
|
||||
let app: Application;
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) {
|
||||
await app.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('case1', async () => {
|
||||
app = mockServer();
|
||||
await app.load();
|
||||
await app.install();
|
||||
app.pm.collection.addField('foo', { type: 'string' });
|
||||
await app.upgrade();
|
||||
const exists = await app.pm.collection.getField('foo').existsInDb();
|
||||
expect(exists).toBeTruthy();
|
||||
});
|
||||
|
||||
test('case2', async () => {
|
||||
app = mockServer();
|
||||
await app.load();
|
||||
app.db.addMigration({
|
||||
name: 'test',
|
||||
up: () => {
|
||||
console.log('up...');
|
||||
},
|
||||
});
|
||||
await app.install();
|
||||
app.pm.collection.addField('foo', { type: 'string' });
|
||||
await app.upgrade();
|
||||
const exists = await app.pm.collection.getField('foo').existsInDb();
|
||||
expect(exists).toBeTruthy();
|
||||
});
|
||||
|
||||
test('case3', async () => {
|
||||
app = mockServer();
|
||||
await app.load();
|
||||
const tableNameWithSchema = app.db.getCollection('applicationPlugins').getTableNameWithSchema();
|
||||
app.db.addMigration({
|
||||
name: 'test',
|
||||
up: async () => {
|
||||
await app.db.sequelize.getQueryInterface().addColumn(tableNameWithSchema, 'foo', {
|
||||
type: DataTypes.STRING,
|
||||
});
|
||||
await app.db.sequelize.getQueryInterface().addConstraint(tableNameWithSchema, {
|
||||
type: 'unique',
|
||||
fields: ['foo'],
|
||||
});
|
||||
},
|
||||
});
|
||||
await app.install();
|
||||
app.pm.collection.addField('foo', { type: 'string', unique: true });
|
||||
await app.upgrade();
|
||||
const exists = await app.pm.collection.getField('foo').existsInDb();
|
||||
expect(exists).toBeTruthy();
|
||||
});
|
||||
|
||||
test('case4', async () => {
|
||||
class P extends Plugin {
|
||||
async load() {
|
||||
this.db.collection({
|
||||
name: 'test',
|
||||
fields: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
app = mockServer({
|
||||
plugins: [P],
|
||||
});
|
||||
await app.load();
|
||||
await app.install();
|
||||
await app.db.getRepository('test').create({ values: {} });
|
||||
await app.install();
|
||||
expect(await app.db.getRepository('test').count()).toBe(1);
|
||||
await app.install({ clean: true });
|
||||
expect(await app.db.getRepository('test').count()).toBe(0);
|
||||
});
|
||||
|
||||
test('app main already exists', async () => {
|
||||
mockServer();
|
||||
expect(() => mockServer()).toThrow('app main already exists');
|
||||
});
|
||||
|
||||
test('command', async () => {
|
||||
const loadFn = jest.fn();
|
||||
app = mockServer();
|
||||
const command = app.command('foo');
|
||||
command.command('bar').action(() => loadFn());
|
||||
await app.runCommand('foo', 'bar');
|
||||
expect(loadFn).toBeCalled();
|
||||
expect(loadFn).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
@ -47,7 +47,7 @@ describe('application', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
return app.db.close();
|
||||
return app.destroy();
|
||||
});
|
||||
|
||||
it('resourcer.define', async () => {
|
||||
@ -126,5 +126,26 @@ describe('application', () => {
|
||||
expect(response.body).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should create application with plugins config', async () => {});
|
||||
it('should call application with command', async () => {
|
||||
await app.runCommand('start');
|
||||
const jestFn = jest.fn();
|
||||
|
||||
app.on('beforeInstall', async () => {
|
||||
jestFn();
|
||||
});
|
||||
|
||||
const runningJest = jest.fn();
|
||||
|
||||
app.on('maintaining', ({ status }) => {
|
||||
if (status === 'command_running') {
|
||||
runningJest();
|
||||
}
|
||||
});
|
||||
|
||||
await app.runCommand('install');
|
||||
|
||||
expect(runningJest).toBeCalledTimes(1);
|
||||
|
||||
expect(jestFn).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
@ -35,6 +35,8 @@ describe('config', () => {
|
||||
|
||||
const app = new Application(config);
|
||||
|
||||
await app.load();
|
||||
|
||||
const appPluginA = app.getPlugin('test-a');
|
||||
|
||||
expect(appPluginA).toBeInstanceOf(TestA);
|
||||
|
@ -43,7 +43,7 @@ describe('application', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
return app.db.close();
|
||||
return app.destroy();
|
||||
});
|
||||
|
||||
it('resourcer.define', async () => {
|
||||
|
314
packages/core/server/src/__tests__/gateway.test.ts
Normal file
314
packages/core/server/src/__tests__/gateway.test.ts
Normal file
@ -0,0 +1,314 @@
|
||||
import { startServerWithRandomPort, supertest, waitSecond } from '@nocobase/test';
|
||||
import { Gateway } from '../gateway';
|
||||
import Application from '../application';
|
||||
import ws from 'ws';
|
||||
import { errors } from '../gateway/errors';
|
||||
import { AppSupervisor } from '../app-supervisor';
|
||||
|
||||
describe('gateway', () => {
|
||||
let gateway: Gateway;
|
||||
|
||||
beforeEach(() => {
|
||||
gateway = Gateway.getInstance();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await gateway.destroy();
|
||||
await AppSupervisor.getInstance().destroy();
|
||||
});
|
||||
|
||||
describe('http api', () => {
|
||||
it('should return error when app not found', async () => {
|
||||
const res = await supertest.agent(gateway.getCallback()).get('/api/app:getInfo');
|
||||
expect(res.status).toBe(503);
|
||||
const data = res.body;
|
||||
|
||||
expect(data).toMatchObject({
|
||||
error: {
|
||||
code: 'APP_INITIALIZING',
|
||||
message: `application main is initializing`,
|
||||
status: 503,
|
||||
maintaining: true,
|
||||
},
|
||||
});
|
||||
|
||||
const res2 = await supertest.agent(gateway.getCallback()).get('/api/app:getInfo');
|
||||
expect(res2.status).toBe(404);
|
||||
const data2 = res2.body;
|
||||
|
||||
expect(data2).toMatchObject({
|
||||
error: {
|
||||
code: 'APP_NOT_FOUND',
|
||||
message: `application main not found`,
|
||||
status: 404,
|
||||
maintaining: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should match error structure', async () => {
|
||||
const main = new Application({
|
||||
database: {
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await supertest.agent(gateway.getCallback()).get('/api/app:getInfo');
|
||||
expect(res.status).toBe(503);
|
||||
const data = res.body;
|
||||
|
||||
expect(data).toMatchObject({
|
||||
error: {
|
||||
code: 'APP_INITIALIZED',
|
||||
message: errors.APP_INITIALIZED.message({ app: main }),
|
||||
status: 503,
|
||||
maintaining: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when app not installed', async () => {
|
||||
const main = new Application({
|
||||
database: {
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
},
|
||||
});
|
||||
|
||||
// app should have error when not installed
|
||||
await main.runAsCLI(['start'], { from: 'user' });
|
||||
|
||||
const res = await supertest.agent(gateway.getCallback()).get('/api/app:getInfo');
|
||||
expect(res.status).toBe(503);
|
||||
const data = res.body;
|
||||
|
||||
expect(data).toMatchObject({
|
||||
error: {
|
||||
code: 'APP_NOT_INSTALLED_ERROR',
|
||||
message: errors.APP_ERROR.message({ app: main }),
|
||||
command: {
|
||||
name: 'start',
|
||||
},
|
||||
status: 503,
|
||||
maintaining: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return running message when app is command running status', async () => {
|
||||
const main = new Application({
|
||||
database: {
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
},
|
||||
});
|
||||
|
||||
main.on('beforeInstall', async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
const installPromise = main.runAsCLI(['install'], { from: 'user' });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const res = await supertest.agent(gateway.getCallback()).get('/api/app:getInfo');
|
||||
const data = res.body;
|
||||
|
||||
expect(data).toMatchObject({
|
||||
error: {
|
||||
status: 503,
|
||||
maintaining: true,
|
||||
message: 'call beforeInstall hook...',
|
||||
code: 'APP_COMMANDING',
|
||||
command: {
|
||||
name: 'install',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await installPromise;
|
||||
});
|
||||
});
|
||||
|
||||
describe('websocket api', () => {
|
||||
let wsClient;
|
||||
let port;
|
||||
|
||||
let messages: Array<string>;
|
||||
|
||||
const connectClient = (port) => {
|
||||
wsClient = new ws(`ws://localhost:${port}/ws`);
|
||||
wsClient.on('message', (data) => {
|
||||
const message = data.toString();
|
||||
messages.push(message);
|
||||
});
|
||||
|
||||
// await connection established
|
||||
return new Promise((resolve) => {
|
||||
wsClient.on('open', resolve);
|
||||
});
|
||||
};
|
||||
|
||||
const getLastMessage = () => {
|
||||
return JSON.parse(messages[messages.length - 1]);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
messages = [];
|
||||
port = await startServerWithRandomPort(gateway.startHttpServer.bind(gateway));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
console.log(messages);
|
||||
|
||||
wsClient.close();
|
||||
|
||||
await new Promise((resolve) => {
|
||||
wsClient.on('close', resolve);
|
||||
});
|
||||
|
||||
await waitSecond();
|
||||
});
|
||||
|
||||
it('should receive app error message', async () => {
|
||||
await connectClient(port);
|
||||
|
||||
await waitSecond();
|
||||
|
||||
// should receive two messages
|
||||
|
||||
// first message is app initializing
|
||||
const firstMessage = messages[0];
|
||||
expect(JSON.parse(firstMessage)).toMatchObject({
|
||||
type: 'maintaining',
|
||||
payload: {
|
||||
code: 'APP_INITIALIZING',
|
||||
message: 'application main is initializing',
|
||||
},
|
||||
});
|
||||
|
||||
//second message is app not found
|
||||
expect(getLastMessage()).toMatchObject({
|
||||
type: 'maintaining',
|
||||
payload: { message: 'application main not found', code: 'APP_NOT_FOUND' },
|
||||
});
|
||||
|
||||
const app = new Application({
|
||||
database: {
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
},
|
||||
});
|
||||
|
||||
await waitSecond();
|
||||
|
||||
expect(getLastMessage()).toMatchObject({
|
||||
type: 'maintaining',
|
||||
payload: {
|
||||
code: 'APP_INITIALIZED',
|
||||
},
|
||||
});
|
||||
|
||||
await app.runAsCLI(['start'], { from: 'user' });
|
||||
await waitSecond();
|
||||
|
||||
expect(getLastMessage()).toMatchObject({
|
||||
type: 'maintaining',
|
||||
payload: {
|
||||
code: 'APP_NOT_INSTALLED_ERROR',
|
||||
message: errors.APP_ERROR.message({ app }),
|
||||
command: {
|
||||
name: 'start',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const jestFn = jest.fn();
|
||||
|
||||
app.on('beforeInstall', async () => {
|
||||
jestFn();
|
||||
});
|
||||
|
||||
const runningJest = jest.fn();
|
||||
app.on('maintaining', ({ status }) => {
|
||||
if (status === 'command_running') {
|
||||
runningJest();
|
||||
}
|
||||
});
|
||||
|
||||
await app.runAsCLI(['install'], { from: 'user' });
|
||||
|
||||
expect(jestFn).toBeCalledTimes(1);
|
||||
expect(runningJest).toBeCalledTimes(1);
|
||||
|
||||
await waitSecond();
|
||||
expect(getLastMessage()).toMatchObject({
|
||||
type: 'maintaining',
|
||||
payload: {
|
||||
code: 'APP_RUNNING',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should receive app running message when command end', async () => {
|
||||
await connectClient(port);
|
||||
const app = new Application({
|
||||
database: {
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
},
|
||||
});
|
||||
|
||||
await waitSecond();
|
||||
|
||||
await app.runCommand('start');
|
||||
await app.runCommand('install');
|
||||
await app.runCommand('db:auth');
|
||||
|
||||
await waitSecond();
|
||||
|
||||
expect(getLastMessage()).toMatchObject({
|
||||
type: 'maintaining',
|
||||
payload: {
|
||||
code: 'APP_RUNNING',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should receive app stopped when stop app', async () => {
|
||||
await connectClient(port);
|
||||
const app = new Application({
|
||||
database: {
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
},
|
||||
});
|
||||
|
||||
await waitSecond();
|
||||
|
||||
await app.runCommand('start');
|
||||
await app.runCommand('install');
|
||||
await waitSecond();
|
||||
|
||||
expect(getLastMessage()).toMatchObject({
|
||||
type: 'maintaining',
|
||||
payload: {
|
||||
code: 'APP_RUNNING',
|
||||
},
|
||||
});
|
||||
|
||||
await app.runCommand('stop');
|
||||
|
||||
await waitSecond();
|
||||
expect(getLastMessage()).toMatchObject({
|
||||
type: 'maintaining',
|
||||
payload: {
|
||||
code: 'APP_STOPPED',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -29,7 +29,7 @@ describe('i18next', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
return app.db.close();
|
||||
return app.destroy();
|
||||
});
|
||||
|
||||
it('global', async () => {
|
||||
|
@ -2,14 +2,22 @@ import Application from '../application';
|
||||
import { Plugin } from '../plugin';
|
||||
|
||||
describe('application life cycle', () => {
|
||||
it('should start application', async () => {
|
||||
const app = new Application({
|
||||
let app: Application;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = new Application({
|
||||
database: {
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should start application', async () => {
|
||||
const loadFn = jest.fn();
|
||||
const installFn = jest.fn();
|
||||
|
||||
@ -35,19 +43,4 @@ describe('application life cycle', () => {
|
||||
await app.install();
|
||||
expect(installFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should listen application', async () => {
|
||||
const app = new Application({
|
||||
database: {
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
},
|
||||
});
|
||||
|
||||
await app.start({ listen: { port: 13090 } });
|
||||
expect(app.listenServer).not.toBeNull();
|
||||
|
||||
await app.stop();
|
||||
expect(app.listenServer).toBeNull();
|
||||
});
|
||||
});
|
||||
|
@ -1,55 +1,8 @@
|
||||
import { AppSupervisor } from '@nocobase/server';
|
||||
import { mockServer, MockServer } from '@nocobase/test';
|
||||
import { uid } from '@nocobase/utils';
|
||||
import { IncomingMessage } from 'http';
|
||||
import * as url from 'url';
|
||||
import Application from '../application';
|
||||
|
||||
describe('multiple apps', () => {
|
||||
it('should emit beforeGetApplication event', async () => {
|
||||
const beforeGetApplicationFn = jest.fn();
|
||||
|
||||
const app = mockServer();
|
||||
|
||||
app.on('beforeGetApplication', beforeGetApplicationFn);
|
||||
|
||||
app.appManager.addSubApp(
|
||||
new Application({
|
||||
database: app.db,
|
||||
name: 'sub1',
|
||||
}),
|
||||
);
|
||||
|
||||
app.appManager.setAppSelector(() => 'sub1');
|
||||
|
||||
await app.agent().resource('test').test({});
|
||||
|
||||
await app.agent().resource('test').test({});
|
||||
|
||||
expect(beforeGetApplicationFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should listen stop event', async () => {
|
||||
const app = mockServer();
|
||||
|
||||
const subApp1 = app.appManager.addSubApp(
|
||||
new Application({
|
||||
database: app.db,
|
||||
name: 'sub1',
|
||||
}),
|
||||
);
|
||||
|
||||
const subApp1StopFn = jest.fn();
|
||||
|
||||
subApp1.on('afterStop', subApp1StopFn);
|
||||
|
||||
await app.stop();
|
||||
|
||||
expect(subApp1StopFn).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple application', () => {
|
||||
let app: MockServer;
|
||||
beforeEach(async () => {
|
||||
@ -67,13 +20,11 @@ describe('multiple application', () => {
|
||||
const sub2 = `a_${uid()}`;
|
||||
const sub3 = `a_${uid()}`;
|
||||
|
||||
const subApp1 = app.appManager.addSubApp(
|
||||
new Application({
|
||||
const subApp1 = new Application({
|
||||
database: app.db,
|
||||
acl: false,
|
||||
name: sub1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
subApp1.resourcer.define({
|
||||
name: 'test',
|
||||
@ -84,13 +35,11 @@ describe('multiple application', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const subApp2 = app.appManager.addSubApp(
|
||||
new Application({
|
||||
const subApp2 = new Application({
|
||||
database: app.db,
|
||||
acl: false,
|
||||
name: sub2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
subApp2.resourcer.define({
|
||||
name: 'test',
|
||||
@ -101,28 +50,8 @@ describe('multiple application', () => {
|
||||
},
|
||||
});
|
||||
|
||||
let response = await app.agent().resource('test').test();
|
||||
expect(response.statusCode).toEqual(404);
|
||||
|
||||
app.appManager.setAppSelector((req: IncomingMessage) => {
|
||||
const queryObject = url.parse(req.url, true).query;
|
||||
return queryObject['app'] as string;
|
||||
});
|
||||
|
||||
response = await app.agent().resource('test').test({
|
||||
app: sub1,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
||||
response = await app.agent().resource('test').test({
|
||||
app: sub2,
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
||||
response = await app.agent().resource('test').test({
|
||||
app: sub3,
|
||||
});
|
||||
expect(response.statusCode).toEqual(404);
|
||||
expect(AppSupervisor.getInstance().hasApp(sub1)).toBeTruthy();
|
||||
expect(AppSupervisor.getInstance().hasApp(sub2)).toBeTruthy();
|
||||
expect(AppSupervisor.getInstance().hasApp(sub3)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { Application } from '../application';
|
||||
import { mockServer, MockServer } from '@nocobase/test';
|
||||
import { Plugin } from '../plugin';
|
||||
import Plugin1 from './plugins/plugin1';
|
||||
import Plugin2 from './plugins/plugin2';
|
||||
import Plugin3 from './plugins/plugin3';
|
||||
import { mockServer, MockServer } from '@nocobase/test';
|
||||
|
||||
describe('plugin', () => {
|
||||
let app: MockServer;
|
||||
@ -19,57 +18,6 @@ describe('plugin', () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
describe('define', () => {
|
||||
it('should add plugin with options', async () => {
|
||||
class MyPlugin extends Plugin {
|
||||
getName(): string {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = app.plugin(MyPlugin, {
|
||||
test: 'hello',
|
||||
});
|
||||
|
||||
expect(plugin.options['test']).toEqual('hello');
|
||||
});
|
||||
|
||||
it('plugin name', async () => {
|
||||
interface Options {
|
||||
a?: string;
|
||||
}
|
||||
class MyPlugin extends Plugin<Options> {
|
||||
async load() {
|
||||
this.options.a;
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
return 'MyPlugin';
|
||||
}
|
||||
}
|
||||
const plugin = app.plugin<Options>(MyPlugin, {
|
||||
a: 'aa',
|
||||
});
|
||||
plugin.setOptions({
|
||||
a: 'a',
|
||||
});
|
||||
expect(plugin).toBeInstanceOf(MyPlugin);
|
||||
expect(plugin.getName()).toBe('MyPlugin');
|
||||
});
|
||||
|
||||
it('plugin name', async () => {
|
||||
const plugin = app.plugin(Plugin1);
|
||||
expect(plugin).toBeInstanceOf(Plugin);
|
||||
expect(plugin.getName()).toBe('Plugin1');
|
||||
});
|
||||
|
||||
it('plugin name', async () => {
|
||||
const plugin = app.plugin(Plugin3);
|
||||
expect(plugin).toBeInstanceOf(Plugin3);
|
||||
expect(plugin.getName()).toBe('Plugin3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('load', () => {
|
||||
it('plugin load', async () => {
|
||||
app.plugin(
|
||||
@ -112,7 +60,7 @@ describe('plugin', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable', function () {
|
||||
describe.skip('enable', function () {
|
||||
it('should call beforeEnable', async () => {
|
||||
const beforeEnable = jest.fn();
|
||||
|
||||
|
402
packages/core/server/src/__tests__/pm.test.ts
Normal file
402
packages/core/server/src/__tests__/pm.test.ts
Normal file
@ -0,0 +1,402 @@
|
||||
import { mockDatabase } from '@nocobase/database';
|
||||
import Application, { ApplicationOptions } from '../application';
|
||||
import Plugin from '../plugin';
|
||||
import { PluginManager } from '../plugin-manager';
|
||||
|
||||
const mockServer = (options?: ApplicationOptions) => {
|
||||
return new Application({
|
||||
database: mockDatabase(),
|
||||
acl: false,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
describe('pm', () => {
|
||||
let app: Application;
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) {
|
||||
await app.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('addPreset', async () => {
|
||||
class Plugin1 extends Plugin {}
|
||||
app = mockServer({
|
||||
plugins: [[Plugin1, { name: 'plugin1' }]],
|
||||
});
|
||||
await app.load();
|
||||
expect(app.pm.get('plugin1').enabled).toBeTruthy();
|
||||
expect(app.pm.get(Plugin1).enabled).toBeTruthy();
|
||||
expect(app.pm.get('plugin1').options.isPreset).toBeTruthy();
|
||||
expect(app.pm.get(Plugin1).options.isPreset).toBeTruthy();
|
||||
});
|
||||
|
||||
test('addPreset', async () => {
|
||||
class Plugin1 extends Plugin {}
|
||||
app = mockServer({
|
||||
plugins: [Plugin1],
|
||||
});
|
||||
await app.load();
|
||||
expect(app.pm.get(Plugin1).enabled).toBeTruthy();
|
||||
expect(app.pm.get(Plugin1).options.isPreset).toBeTruthy();
|
||||
});
|
||||
|
||||
test('add', async () => {
|
||||
class Plugin1 extends Plugin {}
|
||||
app = mockServer();
|
||||
await app.pm.add(Plugin1);
|
||||
expect(app.pm.get(Plugin1).enabled).toBeFalsy();
|
||||
expect(app.pm.get(Plugin1).options.isPreset).toBeFalsy();
|
||||
});
|
||||
|
||||
test('add', async () => {
|
||||
class Plugin1 extends Plugin {}
|
||||
app = mockServer();
|
||||
await app.pm.add(Plugin1, { enabled: true });
|
||||
expect(app.pm.get(Plugin1).enabled).toBeTruthy();
|
||||
});
|
||||
|
||||
test('add', async () => {
|
||||
class Plugin1 extends Plugin {}
|
||||
app = mockServer();
|
||||
await app.pm.add(Plugin1, { name: 'plugin1', enabled: true });
|
||||
expect(app.pm.get(Plugin1).enabled).toBeTruthy();
|
||||
expect(app.pm.get('plugin1').enabled).toBeTruthy();
|
||||
expect(app.pm.get(Plugin1).options.isPreset).toBeFalsy();
|
||||
expect(app.pm.get('plugin1').options.isPreset).toBeFalsy();
|
||||
});
|
||||
|
||||
test('load', async () => {
|
||||
const arr = [];
|
||||
class Plugin1 extends Plugin {
|
||||
async afterAdd() {
|
||||
arr.push(1);
|
||||
}
|
||||
async beforeLoad() {
|
||||
arr.push(2);
|
||||
}
|
||||
async load() {
|
||||
arr.push(3);
|
||||
}
|
||||
}
|
||||
class Plugin2 extends Plugin {
|
||||
async afterAdd() {
|
||||
arr.push(4);
|
||||
await app.pm.add(Plugin3, { enabled: true });
|
||||
}
|
||||
async beforeLoad() {
|
||||
arr.push(5);
|
||||
}
|
||||
async load() {
|
||||
arr.push(6);
|
||||
}
|
||||
}
|
||||
class Plugin3 extends Plugin {
|
||||
async afterAdd() {
|
||||
arr.push(7);
|
||||
}
|
||||
async beforeLoad() {
|
||||
arr.push(8);
|
||||
}
|
||||
async load() {
|
||||
arr.push(9);
|
||||
}
|
||||
}
|
||||
app = mockServer({
|
||||
plugins: [Plugin1, Plugin2],
|
||||
});
|
||||
await app.pm.initPlugins();
|
||||
await app.pm.load();
|
||||
await app.pm.load();
|
||||
expect(arr).toEqual([1, 4, 7, 2, 5, 8, 3, 6, 9]);
|
||||
});
|
||||
|
||||
test('install', async () => {
|
||||
const arr = [];
|
||||
class Plugin1 extends Plugin {
|
||||
async install() {
|
||||
arr.push(1);
|
||||
}
|
||||
}
|
||||
class Plugin2 extends Plugin {
|
||||
async install() {
|
||||
arr.push(2);
|
||||
await app.pm.add(Plugin3, { enabled: true });
|
||||
await app.pm.install();
|
||||
}
|
||||
}
|
||||
class Plugin3 extends Plugin {
|
||||
async install() {
|
||||
arr.push(3);
|
||||
}
|
||||
}
|
||||
app = mockServer({
|
||||
plugins: [Plugin1, Plugin2],
|
||||
});
|
||||
await app.load();
|
||||
await app.pm.install();
|
||||
await app.pm.install();
|
||||
expect(arr).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('enable', async () => {
|
||||
app = mockServer();
|
||||
await app.load();
|
||||
await expect(() => app.pm.enable('Plugin0')).rejects.toThrow('Plugin0 plugin does not exist');
|
||||
});
|
||||
|
||||
test('enable', async () => {
|
||||
const loadFn = jest.fn();
|
||||
class Plugin1 extends Plugin {
|
||||
async beforeEnable() {
|
||||
loadFn();
|
||||
}
|
||||
async install() {
|
||||
loadFn();
|
||||
}
|
||||
async afterEnable() {
|
||||
loadFn();
|
||||
}
|
||||
}
|
||||
app = mockServer({
|
||||
plugins: [[Plugin1, { name: 'Plugin1' }]],
|
||||
});
|
||||
await app.load();
|
||||
await app.pm.enable('Plugin1');
|
||||
expect(loadFn).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('enable', async () => {
|
||||
const resolvePlugin = PluginManager.resolvePlugin;
|
||||
PluginManager.resolvePlugin = (pluginName) => {
|
||||
return Plugin1;
|
||||
};
|
||||
const loadFn = jest.fn();
|
||||
class Plugin1 extends Plugin {
|
||||
async beforeEnable() {
|
||||
loadFn();
|
||||
}
|
||||
async install() {
|
||||
loadFn();
|
||||
}
|
||||
async afterEnable() {
|
||||
loadFn();
|
||||
}
|
||||
}
|
||||
app = mockServer();
|
||||
await app.load();
|
||||
await app.install();
|
||||
await app.pm.repository.create({
|
||||
values: {
|
||||
name: 'Plugin1',
|
||||
enabled: true,
|
||||
installed: true,
|
||||
},
|
||||
});
|
||||
await app.reload();
|
||||
expect(app.pm.get('Plugin1').enabled).toBeTruthy();
|
||||
expect(app.pm.get('Plugin1').installed).toBeTruthy();
|
||||
await app.pm.enable('Plugin1');
|
||||
expect(loadFn).not.toBeCalled();
|
||||
PluginManager.resolvePlugin = resolvePlugin;
|
||||
});
|
||||
|
||||
test('enable', async () => {
|
||||
const resolvePlugin = PluginManager.resolvePlugin;
|
||||
PluginManager.resolvePlugin = (pluginName) => {
|
||||
return Plugin1;
|
||||
};
|
||||
const loadFn = jest.fn();
|
||||
class Plugin1 extends Plugin {
|
||||
async beforeEnable() {
|
||||
loadFn();
|
||||
}
|
||||
async install() {
|
||||
loadFn();
|
||||
}
|
||||
async afterEnable() {
|
||||
loadFn();
|
||||
}
|
||||
}
|
||||
app = mockServer();
|
||||
await app.load();
|
||||
await app.install();
|
||||
await app.pm.repository.create({
|
||||
values: {
|
||||
name: 'Plugin1',
|
||||
},
|
||||
});
|
||||
await app.reload();
|
||||
await app.pm.enable('Plugin1');
|
||||
expect(app.pm.get('Plugin1').enabled).toBeTruthy();
|
||||
expect(app.pm.get('Plugin1').installed).toBeTruthy();
|
||||
expect(loadFn).toBeCalled();
|
||||
expect(loadFn).toBeCalledTimes(3);
|
||||
await app.pm.enable('Plugin1');
|
||||
expect(loadFn).toBeCalledTimes(3);
|
||||
await app.pm.disable('Plugin1');
|
||||
await app.pm.enable('Plugin1');
|
||||
expect(loadFn).toBeCalledTimes(5);
|
||||
PluginManager.resolvePlugin = resolvePlugin;
|
||||
});
|
||||
|
||||
test('enable', async () => {
|
||||
const loadFn = jest.fn();
|
||||
class Plugin1 extends Plugin {
|
||||
async beforeEnable() {
|
||||
loadFn();
|
||||
}
|
||||
async install() {
|
||||
loadFn();
|
||||
}
|
||||
async afterEnable() {
|
||||
loadFn();
|
||||
}
|
||||
}
|
||||
class Plugin2 extends Plugin {
|
||||
async beforeEnable() {
|
||||
loadFn();
|
||||
}
|
||||
async install() {
|
||||
loadFn();
|
||||
}
|
||||
async afterEnable() {
|
||||
loadFn();
|
||||
}
|
||||
}
|
||||
const resolvePlugin = PluginManager.resolvePlugin;
|
||||
PluginManager.resolvePlugin = (pluginName: string) => {
|
||||
return {
|
||||
Plugin1,
|
||||
Plugin2,
|
||||
}[pluginName];
|
||||
};
|
||||
app = mockServer();
|
||||
await app.load();
|
||||
await app.install();
|
||||
await app.pm.repository.create({
|
||||
values: [
|
||||
{
|
||||
name: 'Plugin1',
|
||||
},
|
||||
{
|
||||
name: 'Plugin2',
|
||||
},
|
||||
],
|
||||
});
|
||||
await app.reload();
|
||||
await app.pm.enable(['Plugin1', 'Plugin2']);
|
||||
expect(app.pm.get('Plugin1').enabled).toBeTruthy();
|
||||
expect(app.pm.get('Plugin1').installed).toBeTruthy();
|
||||
expect(app.pm.get('Plugin2').enabled).toBeTruthy();
|
||||
expect(app.pm.get('Plugin2').installed).toBeTruthy();
|
||||
expect(loadFn).toBeCalled();
|
||||
expect(loadFn).toBeCalledTimes(6);
|
||||
await app.pm.enable(['Plugin1', 'Plugin2']);
|
||||
expect(loadFn).toBeCalledTimes(6);
|
||||
PluginManager.resolvePlugin = resolvePlugin;
|
||||
});
|
||||
|
||||
test('disable', async () => {
|
||||
const resolvePlugin = PluginManager.resolvePlugin;
|
||||
PluginManager.resolvePlugin = (pluginName) => {
|
||||
return Plugin1;
|
||||
};
|
||||
const loadFn = jest.fn();
|
||||
class Plugin1 extends Plugin {
|
||||
async beforeDisable() {
|
||||
loadFn();
|
||||
}
|
||||
async afterDisable() {
|
||||
loadFn();
|
||||
}
|
||||
}
|
||||
app = mockServer();
|
||||
await app.load();
|
||||
await app.install();
|
||||
await app.pm.repository.create({
|
||||
values: {
|
||||
name: 'Plugin1',
|
||||
},
|
||||
});
|
||||
await app.reload();
|
||||
expect(app.pm.get('Plugin1').enabled).toBeFalsy();
|
||||
expect(app.pm.get('Plugin1').installed).toBeFalsy();
|
||||
await app.pm.disable('Plugin1');
|
||||
expect(loadFn).not.toBeCalled();
|
||||
PluginManager.resolvePlugin = resolvePlugin;
|
||||
});
|
||||
|
||||
test('disable', async () => {
|
||||
const resolvePlugin = PluginManager.resolvePlugin;
|
||||
PluginManager.resolvePlugin = (pluginName) => {
|
||||
return Plugin1;
|
||||
};
|
||||
const loadFn = jest.fn();
|
||||
class Plugin1 extends Plugin {
|
||||
async beforeDisable() {
|
||||
loadFn();
|
||||
}
|
||||
async afterDisable() {
|
||||
loadFn();
|
||||
}
|
||||
}
|
||||
app = mockServer();
|
||||
await app.load();
|
||||
await app.install();
|
||||
await app.pm.repository.create({
|
||||
values: {
|
||||
name: 'Plugin1',
|
||||
enabled: true,
|
||||
installed: true,
|
||||
},
|
||||
});
|
||||
await app.reload();
|
||||
expect(app.pm.get('Plugin1').enabled).toBeTruthy();
|
||||
expect(app.pm.get('Plugin1').installed).toBeTruthy();
|
||||
await app.pm.disable('Plugin1');
|
||||
expect(loadFn).toBeCalled();
|
||||
expect(loadFn).toBeCalledTimes(2);
|
||||
expect(app.pm.get('Plugin1').enabled).toBeFalsy();
|
||||
expect(app.pm.get('Plugin1').installed).toBeTruthy();
|
||||
PluginManager.resolvePlugin = resolvePlugin;
|
||||
});
|
||||
|
||||
test('install', async () => {
|
||||
class Plugin0 extends Plugin {
|
||||
async install() {
|
||||
await this.app.pm.repository.create({
|
||||
values: {
|
||||
name: 'Plugin1',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
await this.app.pm.repository.init();
|
||||
await this.app.pm.load();
|
||||
await this.app.pm.install();
|
||||
}
|
||||
}
|
||||
class Plugin1 extends Plugin {}
|
||||
class Plugin2 extends Plugin {}
|
||||
const resolvePlugin = PluginManager.resolvePlugin;
|
||||
PluginManager.resolvePlugin = (pluginName: string) => {
|
||||
return {
|
||||
Plugin0,
|
||||
Plugin1,
|
||||
Plugin2,
|
||||
}[pluginName];
|
||||
};
|
||||
app = mockServer({
|
||||
plugins: ['Plugin0'],
|
||||
});
|
||||
await app.load();
|
||||
await app.install();
|
||||
expect(app.pm.get('Plugin1').enabled).toBeTruthy();
|
||||
expect(app.pm.get('Plugin1').installed).toBeTruthy();
|
||||
const record = await app.pm.repository.findOne({ filter: { name: 'Plugin1' } });
|
||||
expect(record.enabled).toBeTruthy();
|
||||
expect(record.installed).toBeTruthy();
|
||||
PluginManager.resolvePlugin = resolvePlugin;
|
||||
});
|
||||
});
|
@ -1,112 +0,0 @@
|
||||
import http, { IncomingMessage, ServerResponse } from 'http';
|
||||
import Application from './application';
|
||||
|
||||
type AppSelectorReturn = Application | string | undefined | null;
|
||||
|
||||
type AppSelector = (req: IncomingMessage) => AppSelectorReturn | Promise<AppSelectorReturn>;
|
||||
|
||||
export class AppManager {
|
||||
public applications: Map<string, Application> = new Map<string, Application>();
|
||||
public app: Application;
|
||||
public runningMode: 'single' | 'multiple' = 'multiple';
|
||||
public singleAppName: string | null = null;
|
||||
|
||||
constructor(app: Application) {
|
||||
this.bindMainApplication(app);
|
||||
|
||||
// if STARTUP_SUBAPP is set, run in single mode
|
||||
if (process.env.STARTUP_SUBAPP) {
|
||||
this.singleAppName = process.env.STARTUP_SUBAPP;
|
||||
this.runningMode = 'single';
|
||||
}
|
||||
}
|
||||
|
||||
bindMainApplication(mainApp: Application) {
|
||||
this.app = mainApp;
|
||||
const passEventToSubApps = (eventName, method) => {
|
||||
mainApp.on(eventName, async (mainApp, options) => {
|
||||
console.log(`receive event ${eventName} from ${mainApp.name}`);
|
||||
for (const application of this.applications.values()) {
|
||||
console.log(`pass ${eventName} to ${application.name} `);
|
||||
await application[method](options);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
passEventToSubApps('beforeDestroy', 'destroy');
|
||||
passEventToSubApps('beforeStop', 'stop');
|
||||
}
|
||||
|
||||
appSelector: AppSelector = async (req: IncomingMessage) => this.app;
|
||||
|
||||
addSubApp(application): Application {
|
||||
this.applications.set(application.name, application);
|
||||
this.app.emit('afterSubAppAdded', application);
|
||||
return application;
|
||||
}
|
||||
|
||||
async removeApplication(name: string) {
|
||||
const application = this.applications.get(name);
|
||||
if (!application) {
|
||||
return;
|
||||
}
|
||||
|
||||
await application.destroy();
|
||||
|
||||
console.log(`remove application ${name}`);
|
||||
this.applications.delete(name);
|
||||
}
|
||||
|
||||
setAppSelector(selector: AppSelector) {
|
||||
this.appSelector = selector;
|
||||
}
|
||||
|
||||
listen(...args) {
|
||||
const server = http.createServer(this.callback());
|
||||
return server.listen(...args);
|
||||
}
|
||||
|
||||
async getApplication(appName: string, options = {}): Promise<null | Application> {
|
||||
await this.app.emitAsync('beforeGetApplication', {
|
||||
appManager: this,
|
||||
name: appName,
|
||||
options,
|
||||
});
|
||||
|
||||
return this.applications.get(appName);
|
||||
}
|
||||
|
||||
callback() {
|
||||
return async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const appManager = this.app.appManager;
|
||||
|
||||
let handleApp;
|
||||
if (this.runningMode === 'single') {
|
||||
handleApp = this.singleAppName;
|
||||
} else {
|
||||
handleApp = (await appManager.appSelector(req)) || appManager.app;
|
||||
}
|
||||
|
||||
if (typeof handleApp === 'string') {
|
||||
handleApp = await appManager.getApplication(handleApp);
|
||||
if (!handleApp) {
|
||||
res.statusCode = 404;
|
||||
return res.end(
|
||||
JSON.stringify({
|
||||
redirectTo: process.env.APP_NOT_FOUND_REDIRECT_TO,
|
||||
errors: [
|
||||
{
|
||||
message: 'Application Not Found',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (handleApp.stopped) await handleApp.start();
|
||||
}
|
||||
|
||||
handleApp.callback()(req, res);
|
||||
};
|
||||
}
|
||||
}
|
306
packages/core/server/src/app-supervisor.ts
Normal file
306
packages/core/server/src/app-supervisor.ts
Normal file
@ -0,0 +1,306 @@
|
||||
import { applyMixins, AsyncEmitter } from '@nocobase/utils';
|
||||
import { Mutex } from 'async-mutex';
|
||||
import { EventEmitter } from 'events';
|
||||
import Application, { ApplicationOptions, MaintainingCommandStatus } from './application';
|
||||
|
||||
type BootOptions = {
|
||||
appName: string;
|
||||
options: any;
|
||||
appSupervisor: AppSupervisor;
|
||||
};
|
||||
|
||||
type AppBootstrapper = (bootOptions: BootOptions) => Promise<void>;
|
||||
|
||||
type AppStatus = 'initializing' | 'initialized' | 'running' | 'commanding' | 'stopped' | 'error' | 'not_found';
|
||||
|
||||
export class AppSupervisor extends EventEmitter implements AsyncEmitter {
|
||||
private static instance: AppSupervisor;
|
||||
public runningMode: 'single' | 'multiple' = 'multiple';
|
||||
public singleAppName: string | null = null;
|
||||
declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
|
||||
public apps: {
|
||||
[appName: string]: Application;
|
||||
} = {};
|
||||
|
||||
public appErrors: {
|
||||
[appName: string]: Error;
|
||||
} = {};
|
||||
|
||||
public appStatus: {
|
||||
[appName: string]: AppStatus;
|
||||
} = {};
|
||||
|
||||
public lastMaintainingMessage: {
|
||||
[appName: string]: string;
|
||||
} = {};
|
||||
|
||||
public statusBeforeCommanding: {
|
||||
[appName: string]: AppStatus;
|
||||
} = {};
|
||||
|
||||
private appMutexes = {};
|
||||
private appBootstrapper: AppBootstrapper = null;
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
|
||||
if (process.env.STARTUP_SUBAPP) {
|
||||
this.runningMode = 'single';
|
||||
this.singleAppName = process.env.STARTUP_SUBAPP;
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): AppSupervisor {
|
||||
if (!AppSupervisor.instance) {
|
||||
AppSupervisor.instance = new AppSupervisor();
|
||||
}
|
||||
|
||||
return AppSupervisor.instance;
|
||||
}
|
||||
|
||||
setAppError(appName: string, error: Error) {
|
||||
this.appErrors[appName] = error;
|
||||
|
||||
this.emit('appError', {
|
||||
appName: appName,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
hasAppError(appName: string) {
|
||||
return !!this.appErrors[appName];
|
||||
}
|
||||
|
||||
clearAppError(appName: string) {
|
||||
delete this.appErrors[appName];
|
||||
}
|
||||
|
||||
async reset() {
|
||||
const appNames = Object.keys(this.apps);
|
||||
for (const appName of appNames) {
|
||||
await this.removeApp(appName);
|
||||
}
|
||||
|
||||
this.appBootstrapper = null;
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
await this.reset();
|
||||
AppSupervisor.instance = null;
|
||||
}
|
||||
|
||||
setAppStatus(appName: string, status: AppStatus) {
|
||||
if (this.appStatus[appName] === status) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.appStatus[appName] = status;
|
||||
|
||||
this.emit('appStatusChanged', {
|
||||
appName,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
getMutexOfApp(appName: string) {
|
||||
if (!this.appMutexes[appName]) {
|
||||
this.appMutexes[appName] = new Mutex();
|
||||
}
|
||||
|
||||
return this.appMutexes[appName];
|
||||
}
|
||||
|
||||
async bootStrapApp(appName: string, options = {}) {
|
||||
await this.getMutexOfApp(appName).runExclusive(async () => {
|
||||
if (!this.hasApp(appName)) {
|
||||
this.setAppStatus(appName, 'initializing');
|
||||
|
||||
if (this.appBootstrapper) {
|
||||
await this.appBootstrapper({
|
||||
appSupervisor: this,
|
||||
appName,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.hasApp(appName)) {
|
||||
this.setAppStatus(appName, 'not_found');
|
||||
} else if (!this.getAppStatus(appName) || this.getAppStatus(appName) == 'initializing') {
|
||||
this.setAppStatus(appName, 'initialized');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getApp(
|
||||
appName: string,
|
||||
options: {
|
||||
withOutBootStrap?: boolean;
|
||||
[key: string]: any;
|
||||
} = {},
|
||||
) {
|
||||
if (!options.withOutBootStrap) {
|
||||
await this.bootStrapApp(appName, options);
|
||||
}
|
||||
|
||||
return this.apps[appName];
|
||||
}
|
||||
|
||||
setAppBootstrapper(appBootstrapper: AppBootstrapper) {
|
||||
this.appBootstrapper = appBootstrapper;
|
||||
}
|
||||
|
||||
getAppStatus(appName: string, defaultStatus?: AppStatus): AppStatus | null {
|
||||
const status = this.appStatus[appName];
|
||||
|
||||
if (status === undefined && defaultStatus !== undefined) {
|
||||
return defaultStatus;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
bootMainApp(options: ApplicationOptions) {
|
||||
return new Application(options);
|
||||
}
|
||||
|
||||
hasApp(appName: string): boolean {
|
||||
return !!this.apps[appName];
|
||||
}
|
||||
|
||||
// add app into supervisor
|
||||
addApp(app: Application) {
|
||||
// if there is already an app with the same name, throw error
|
||||
if (this.apps[app.name]) {
|
||||
throw new Error(`app ${app.name} already exists`);
|
||||
}
|
||||
|
||||
console.log(`add app ${app.name} into supervisor`);
|
||||
|
||||
this.bindAppEvents(app);
|
||||
|
||||
this.apps[app.name] = app;
|
||||
|
||||
this.emit('afterAppAdded', app);
|
||||
|
||||
if (!this.getAppStatus(app.name) || this.getAppStatus(app.name) == 'not_found') {
|
||||
this.setAppStatus(app.name, 'initialized');
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// get registered app names
|
||||
async getAppsNames() {
|
||||
const apps = Object.values(this.apps);
|
||||
|
||||
return apps.map((app) => app.name);
|
||||
}
|
||||
|
||||
async removeApp(appName: string) {
|
||||
if (!this.apps[appName]) {
|
||||
console.log(`app ${appName} not exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
// call app.destroy
|
||||
await this.apps[appName].runCommand('destroy');
|
||||
}
|
||||
|
||||
subApps() {
|
||||
return Object.values(this.apps).filter((app) => app.name !== 'main');
|
||||
}
|
||||
|
||||
on(eventName: string | symbol, listener: (...args: any[]) => void): this {
|
||||
const listeners = this.listeners(eventName);
|
||||
const listenerName = listener.name;
|
||||
|
||||
if (listenerName !== '') {
|
||||
const exists = listeners.find((l) => l.name === listenerName);
|
||||
|
||||
if (exists) {
|
||||
super.removeListener(eventName, exists as any);
|
||||
}
|
||||
}
|
||||
|
||||
return super.on(eventName, listener);
|
||||
}
|
||||
|
||||
private bindAppEvents(app: Application) {
|
||||
app.on('afterDestroy', () => {
|
||||
delete this.apps[app.name];
|
||||
delete this.appStatus[app.name];
|
||||
delete this.appErrors[app.name];
|
||||
delete this.lastMaintainingMessage[app.name];
|
||||
delete this.statusBeforeCommanding[app.name];
|
||||
});
|
||||
|
||||
app.on('maintainingMessageChanged', ({ message, maintainingStatus }) => {
|
||||
if (this.lastMaintainingMessage[app.name] === message) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastMaintainingMessage[app.name] = message;
|
||||
|
||||
const appStatus = this.getAppStatus(app.name);
|
||||
|
||||
if (!maintainingStatus && appStatus !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('appMaintainingMessageChanged', {
|
||||
appName: app.name,
|
||||
message,
|
||||
status: appStatus,
|
||||
command: appStatus == 'running' ? null : maintainingStatus.command,
|
||||
});
|
||||
});
|
||||
|
||||
app.on('__started', async () => {
|
||||
this.setAppStatus(app.name, 'running');
|
||||
});
|
||||
|
||||
app.on('afterStop', async () => {
|
||||
this.setAppStatus(app.name, 'stopped');
|
||||
});
|
||||
|
||||
app.on('maintaining', (maintainingStatus: MaintainingCommandStatus) => {
|
||||
const { status } = maintainingStatus;
|
||||
|
||||
switch (status) {
|
||||
case 'command_begin':
|
||||
{
|
||||
this.statusBeforeCommanding[app.name] = this.getAppStatus(app.name);
|
||||
this.setAppStatus(app.name, 'commanding');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'command_running':
|
||||
// emit status changed
|
||||
// this.emit('appMaintainingStatusChanged', maintainingStatus);
|
||||
break;
|
||||
case 'command_end':
|
||||
{
|
||||
const appStatus = this.getAppStatus(app.name);
|
||||
// emit status changed
|
||||
this.emit('appMaintainingStatusChanged', maintainingStatus);
|
||||
|
||||
// not change
|
||||
if (appStatus == 'commanding') {
|
||||
this.setAppStatus(app.name, this.statusBeforeCommanding[app.name]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'command_error':
|
||||
{
|
||||
this.setAppError(app.name, maintainingStatus.error);
|
||||
this.setAppStatus(app.name, 'error');
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
applyMixins(AppSupervisor, [AsyncEmitter]);
|
@ -2,29 +2,31 @@ import { ACL } from '@nocobase/acl';
|
||||
import { registerActions } from '@nocobase/actions';
|
||||
import { actions as authActions, AuthManager } from '@nocobase/auth';
|
||||
import { Cache, createCache, ICacheConfig } from '@nocobase/cache';
|
||||
import Database, { Collection, CollectionOptions, IDatabaseOptions } from '@nocobase/database';
|
||||
import Database, { CollectionOptions, IDatabaseOptions } from '@nocobase/database';
|
||||
import { AppLoggerOptions, createAppLogger, Logger } from '@nocobase/logger';
|
||||
import Resourcer, { ResourceOptions } from '@nocobase/resourcer';
|
||||
import { applyMixins, AsyncEmitter, Toposort, ToposortOptions } from '@nocobase/utils';
|
||||
import chalk from 'chalk';
|
||||
import { Command, CommandOptions, ParseOptions } from 'commander';
|
||||
import { Server } from 'http';
|
||||
import { IncomingMessage, Server, ServerResponse } from 'http';
|
||||
import { i18n, InitOptions } from 'i18next';
|
||||
import Koa, { DefaultContext as KoaDefaultContext, DefaultState as KoaDefaultState } from 'koa';
|
||||
import compose from 'koa-compose';
|
||||
import semver from 'semver';
|
||||
import { promisify } from 'util';
|
||||
import lodash from 'lodash';
|
||||
import { createACL } from './acl';
|
||||
import { AppManager } from './app-manager';
|
||||
import { AppSupervisor } from './app-supervisor';
|
||||
import { registerCli } from './commands';
|
||||
import { createI18n, createResourcer, registerMiddlewares } from './helper';
|
||||
import { ApplicationNotInstall } from './errors/application-not-install';
|
||||
import { createAppProxy, createI18n, createResourcer, getCommandFullName, registerMiddlewares } from './helper';
|
||||
import { ApplicationVersion } from './helpers/application-version';
|
||||
import { Locale } from './locale';
|
||||
import { Plugin } from './plugin';
|
||||
import { InstallOptions, PluginManager } from './plugin-manager';
|
||||
|
||||
const packageJson = require('../package.json');
|
||||
|
||||
export type PluginConfiguration = string | [string, any];
|
||||
export type PluginType = string | typeof Plugin;
|
||||
export type PluginConfiguration = PluginType | [PluginType, any];
|
||||
|
||||
export interface ResourcerOptions {
|
||||
prefix?: string;
|
||||
@ -61,14 +63,6 @@ export interface DefaultContext extends KoaDefaultContext {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface MiddlewareOptions {
|
||||
name?: string;
|
||||
resourceName?: string;
|
||||
resourceNames?: string[];
|
||||
insertBefore?: string;
|
||||
insertAfter?: string;
|
||||
}
|
||||
|
||||
interface ActionsOptions {
|
||||
resourceName?: string;
|
||||
resourceNames?: string[];
|
||||
@ -92,251 +86,169 @@ interface ListenOptions {
|
||||
interface StartOptions {
|
||||
cliArgs?: any[];
|
||||
dbSync?: boolean;
|
||||
listen?: ListenOptions;
|
||||
checkInstall?: boolean;
|
||||
}
|
||||
|
||||
export class ApplicationVersion {
|
||||
protected app: Application;
|
||||
protected collection: Collection;
|
||||
type MaintainingStatus = 'command_begin' | 'command_end' | 'command_running' | 'command_error';
|
||||
|
||||
constructor(app: Application) {
|
||||
this.app = app;
|
||||
if (!app.db.hasCollection('applicationVersion')) {
|
||||
app.db.collection({
|
||||
name: 'applicationVersion',
|
||||
namespace: 'core.applicationVersion',
|
||||
duplicator: 'required',
|
||||
timestamps: false,
|
||||
fields: [{ name: 'value', type: 'string' }],
|
||||
});
|
||||
}
|
||||
this.collection = this.app.db.getCollection('applicationVersion');
|
||||
}
|
||||
|
||||
async get() {
|
||||
if (await this.app.db.collectionExistsInDb('applicationVersion')) {
|
||||
const model = await this.collection.model.findOne();
|
||||
if (!model) {
|
||||
return null;
|
||||
}
|
||||
return model.get('value') as any;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async update() {
|
||||
await this.collection.sync();
|
||||
await this.collection.model.destroy({
|
||||
truncate: true,
|
||||
});
|
||||
|
||||
await this.collection.model.create({
|
||||
value: this.app.getVersion(),
|
||||
});
|
||||
}
|
||||
|
||||
async satisfies(range: string) {
|
||||
if (await this.app.db.collectionExistsInDb('applicationVersion')) {
|
||||
const model: any = await this.collection.model.findOne();
|
||||
const version = model?.value as any;
|
||||
if (!version) {
|
||||
return true;
|
||||
}
|
||||
return semver.satisfies(version, range, { includePrerelease: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
export type MaintainingCommandStatus = {
|
||||
command: {
|
||||
name: string;
|
||||
};
|
||||
status: MaintainingStatus;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export class Application<StateT = DefaultState, ContextT = DefaultContext> extends Koa implements AsyncEmitter {
|
||||
protected _db: Database;
|
||||
protected _logger: Logger;
|
||||
|
||||
protected _resourcer: Resourcer;
|
||||
|
||||
protected _cache: Cache;
|
||||
|
||||
protected _cli: Command;
|
||||
|
||||
protected _i18n: i18n;
|
||||
|
||||
protected _pm: PluginManager;
|
||||
|
||||
protected _acl: ACL;
|
||||
|
||||
protected _appManager: AppManager;
|
||||
|
||||
protected _authManager: AuthManager;
|
||||
|
||||
protected _locales: Locale;
|
||||
|
||||
protected _version: ApplicationVersion;
|
||||
|
||||
protected plugins = new Map<string, Plugin>();
|
||||
|
||||
public listenServer: Server;
|
||||
|
||||
declare middleware: any;
|
||||
|
||||
stopped = false;
|
||||
ready = false;
|
||||
declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
|
||||
public rawOptions: ApplicationOptions;
|
||||
public activatedCommand: {
|
||||
name: string;
|
||||
} = null;
|
||||
public running = false;
|
||||
protected plugins = new Map<string, Plugin>();
|
||||
protected _appSupervisor: AppSupervisor = AppSupervisor.getInstance();
|
||||
protected _started: boolean;
|
||||
private _authenticated = false;
|
||||
private _maintaining = false;
|
||||
private _maintainingCommandStatus: MaintainingCommandStatus;
|
||||
private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null;
|
||||
|
||||
constructor(public options: ApplicationOptions) {
|
||||
super();
|
||||
this.rawOptions = this.name == 'main' ? lodash.cloneDeep(options) : {};
|
||||
this.init();
|
||||
|
||||
this._appSupervisor.addApp(this);
|
||||
}
|
||||
|
||||
protected _loaded: boolean;
|
||||
|
||||
get loaded() {
|
||||
return this._loaded;
|
||||
}
|
||||
|
||||
private _maintainingMessage: string;
|
||||
|
||||
get maintainingMessage() {
|
||||
return this._maintainingMessage;
|
||||
}
|
||||
|
||||
protected _db: Database;
|
||||
|
||||
get db() {
|
||||
return this._db;
|
||||
}
|
||||
|
||||
get cache() {
|
||||
return this._cache;
|
||||
protected _logger: Logger;
|
||||
|
||||
get logger() {
|
||||
return this._logger;
|
||||
}
|
||||
|
||||
protected _resourcer: Resourcer;
|
||||
|
||||
get resourcer() {
|
||||
return this._resourcer;
|
||||
}
|
||||
|
||||
protected _cache: Cache;
|
||||
|
||||
get cache() {
|
||||
return this._cache;
|
||||
}
|
||||
|
||||
protected _cli: Command;
|
||||
|
||||
get cli() {
|
||||
return this._cli;
|
||||
}
|
||||
|
||||
get acl() {
|
||||
return this._acl;
|
||||
}
|
||||
protected _i18n: i18n;
|
||||
|
||||
get i18n() {
|
||||
return this._i18n;
|
||||
}
|
||||
|
||||
protected _pm: PluginManager;
|
||||
|
||||
get pm() {
|
||||
return this._pm;
|
||||
}
|
||||
|
||||
get version() {
|
||||
return this._version;
|
||||
protected _acl: ACL;
|
||||
|
||||
get acl() {
|
||||
return this._acl;
|
||||
}
|
||||
|
||||
get appManager() {
|
||||
return this._appManager;
|
||||
}
|
||||
protected _authManager: AuthManager;
|
||||
|
||||
get authManager() {
|
||||
return this._authManager;
|
||||
}
|
||||
|
||||
get logger() {
|
||||
return this._logger;
|
||||
protected _locales: Locale;
|
||||
|
||||
get locales() {
|
||||
return this._locales;
|
||||
}
|
||||
|
||||
protected _version: ApplicationVersion;
|
||||
|
||||
get version() {
|
||||
return this._version;
|
||||
}
|
||||
|
||||
get log() {
|
||||
return this._logger;
|
||||
}
|
||||
|
||||
get locales() {
|
||||
return this._locales;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.options.name || 'main';
|
||||
}
|
||||
|
||||
protected init() {
|
||||
const options = this.options;
|
||||
|
||||
const logger = createAppLogger(options.logger);
|
||||
this._logger = logger.instance;
|
||||
|
||||
// @ts-ignore
|
||||
this._events = [];
|
||||
// @ts-ignore
|
||||
this._eventsCount = [];
|
||||
|
||||
this.removeAllListeners();
|
||||
|
||||
this.middleware = new Toposort<any>();
|
||||
this.plugins = new Map<string, Plugin>();
|
||||
this._acl = createACL();
|
||||
|
||||
this.use(logger.middleware, { tag: 'logger' });
|
||||
|
||||
if (this._db) {
|
||||
// MaxListenersExceededWarning
|
||||
this._db.removeAllListeners();
|
||||
isMaintaining() {
|
||||
return this._maintaining;
|
||||
}
|
||||
|
||||
this._db = this.createDatabase(options);
|
||||
getMaintaining() {
|
||||
return this._maintainingCommandStatus;
|
||||
}
|
||||
|
||||
this._resourcer = createResourcer(options);
|
||||
this._cli = new Command('nocobase').usage('[command] [options]');
|
||||
this._i18n = createI18n(options);
|
||||
this._cache = createCache(options.cache);
|
||||
this.context.db = this._db;
|
||||
this.context.logger = this._logger;
|
||||
this.context.resourcer = this._resourcer;
|
||||
this.context.cache = this._cache;
|
||||
setMaintaining(_maintainingCommandStatus: MaintainingCommandStatus) {
|
||||
this._maintainingCommandStatus = _maintainingCommandStatus;
|
||||
|
||||
if (this._pm) {
|
||||
this._pm = this._pm.clone();
|
||||
} else {
|
||||
this._pm = new PluginManager({
|
||||
app: this,
|
||||
plugins: options.plugins,
|
||||
this.emit('maintaining', _maintainingCommandStatus);
|
||||
|
||||
if (_maintainingCommandStatus.status == 'command_end') {
|
||||
this._maintaining = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._maintaining = true;
|
||||
}
|
||||
|
||||
setMaintainingMessage(message: string) {
|
||||
this._maintainingMessage = message;
|
||||
|
||||
this.emit('maintainingMessageChanged', {
|
||||
message: this._maintainingMessage,
|
||||
maintainingStatus: this._maintainingCommandStatus,
|
||||
});
|
||||
}
|
||||
|
||||
if (this._appManager) {
|
||||
this._appManager.bindMainApplication(this);
|
||||
} else {
|
||||
this._appManager = new AppManager(this);
|
||||
}
|
||||
|
||||
this._authManager = new AuthManager({
|
||||
authKey: 'X-Authenticator',
|
||||
default: 'basic',
|
||||
});
|
||||
this.resource({
|
||||
name: 'auth',
|
||||
actions: authActions,
|
||||
});
|
||||
this._resourcer.use(this._authManager.middleware(), { tag: 'auth' });
|
||||
|
||||
if (this.options.acl !== false) {
|
||||
this._resourcer.use(this._acl.middleware(), { tag: 'acl', after: ['auth'] });
|
||||
}
|
||||
|
||||
this._locales = new Locale(this);
|
||||
|
||||
registerMiddlewares(this, options);
|
||||
|
||||
if (options.registerActions !== false) {
|
||||
registerActions(this);
|
||||
}
|
||||
|
||||
registerCli(this);
|
||||
|
||||
this._version = new ApplicationVersion(this);
|
||||
}
|
||||
|
||||
private createDatabase(options: ApplicationOptions) {
|
||||
const db = new Database({
|
||||
...(options.database instanceof Database ? options.database.options : options.database),
|
||||
migrator: {
|
||||
context: { app: this },
|
||||
},
|
||||
});
|
||||
|
||||
db.setLogger(this._logger);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
getVersion() {
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
plugin<O = any>(pluginClass: any, options?: O): Plugin {
|
||||
return this.pm.addStatic(pluginClass, options);
|
||||
plugin<O = any>(pluginClass: any, options?: O) {
|
||||
this.log.debug(`add plugin ${pluginClass.name}`);
|
||||
this.pm.addPreset(pluginClass, options);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
@ -353,14 +265,12 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
|
||||
if (!this.listenerCount('error')) this.on('error', this.onerror);
|
||||
|
||||
const handleRequest = (req, res) => {
|
||||
return (req: IncomingMessage, res: ServerResponse) => {
|
||||
const ctx = this.createContext(req, res);
|
||||
|
||||
// @ts-ignore
|
||||
return this.handleRequest(ctx, fn);
|
||||
};
|
||||
|
||||
return handleRequest;
|
||||
}
|
||||
|
||||
collection(options: CollectionOptions) {
|
||||
@ -380,32 +290,54 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
}
|
||||
|
||||
findCommand(name: string): Command {
|
||||
return (<any>this.cli)._findCommand(name);
|
||||
return (this.cli as any)._findCommand(name);
|
||||
}
|
||||
|
||||
async load(options?: any) {
|
||||
if (this._loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.reload) {
|
||||
console.log(`Reload the ${this.name} application configuration`);
|
||||
this.setMaintainingMessage('app reload');
|
||||
this.log.info(`app.reload()`);
|
||||
const oldDb = this._db;
|
||||
this.init();
|
||||
await oldDb.close();
|
||||
}
|
||||
|
||||
this.setMaintainingMessage('init plugins');
|
||||
await this.pm.initPlugins();
|
||||
|
||||
this.setMaintainingMessage('start load');
|
||||
|
||||
this.setMaintainingMessage('emit beforeLoad');
|
||||
await this.emitAsync('beforeLoad', this, options);
|
||||
|
||||
await this.pm.load(options);
|
||||
|
||||
this.setMaintainingMessage('emit afterLoad');
|
||||
await this.emitAsync('afterLoad', this, options);
|
||||
this._loaded = true;
|
||||
}
|
||||
|
||||
async reload(options?: any) {
|
||||
this.log.debug(`start reload`);
|
||||
|
||||
this._loaded = false;
|
||||
|
||||
await this.load({
|
||||
...options,
|
||||
reload: true,
|
||||
});
|
||||
|
||||
this.log.debug('emit afterReload');
|
||||
this.setMaintainingMessage('emit afterReload');
|
||||
await this.emitAsync('afterReload', this, options);
|
||||
this.log.debug(`finish reload`);
|
||||
}
|
||||
|
||||
getPlugin<P extends Plugin>(name: string) {
|
||||
getPlugin<P extends Plugin>(name: string | typeof Plugin) {
|
||||
return this.pm.get(name) as P;
|
||||
}
|
||||
|
||||
@ -413,75 +345,129 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
return this.runAsCLI(argv);
|
||||
}
|
||||
|
||||
async runAsCLI(argv = process.argv, options?: ParseOptions) {
|
||||
try {
|
||||
async authenticate() {
|
||||
if (this._authenticated) {
|
||||
return;
|
||||
}
|
||||
this._authenticated = true;
|
||||
await this.db.auth({ retry: 30 });
|
||||
} catch (error) {
|
||||
console.log(chalk.red(error.message));
|
||||
process.exit(1);
|
||||
await this.dbVersionCheck({ exit: true });
|
||||
await this.db.prepare();
|
||||
}
|
||||
|
||||
await this.dbVersionCheck({ exit: true });
|
||||
async runCommand(command: string, ...args: any[]) {
|
||||
return await this.runAsCLI([command, ...args], { from: 'user' });
|
||||
}
|
||||
|
||||
await this.db.prepare();
|
||||
createCli() {
|
||||
return new Command('nocobase')
|
||||
.usage('[command] [options]')
|
||||
.hook('preAction', async (_, actionCommand) => {
|
||||
this.activatedCommand = {
|
||||
name: getCommandFullName(actionCommand),
|
||||
};
|
||||
|
||||
if (argv?.[2] !== 'upgrade') {
|
||||
await this.load({
|
||||
method: argv?.[2],
|
||||
this.setMaintaining({
|
||||
status: 'command_begin',
|
||||
command: this.activatedCommand,
|
||||
});
|
||||
|
||||
this.setMaintaining({
|
||||
status: 'command_running',
|
||||
command: this.activatedCommand,
|
||||
});
|
||||
|
||||
await this.authenticate();
|
||||
await this.load();
|
||||
})
|
||||
.hook('postAction', async (_, actionCommand) => {
|
||||
if (this._maintainingStatusBeforeCommand?.error && this._started) {
|
||||
await this.restart();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return this.cli.parseAsync(argv, options);
|
||||
async runAsCLI(argv = process.argv, options?: ParseOptions) {
|
||||
if (this.activatedCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._maintainingStatusBeforeCommand = this._maintainingCommandStatus;
|
||||
|
||||
try {
|
||||
const command = await this.cli.parseAsync(argv, options);
|
||||
|
||||
this.setMaintaining({
|
||||
status: 'command_end',
|
||||
command: this.activatedCommand,
|
||||
});
|
||||
|
||||
return command;
|
||||
} catch (error) {
|
||||
console.log(`run command ${this.activatedCommand.name} error:`, error);
|
||||
this.setMaintaining({
|
||||
status: 'command_error',
|
||||
command: this.activatedCommand,
|
||||
error,
|
||||
});
|
||||
} finally {
|
||||
this.activatedCommand = null;
|
||||
}
|
||||
}
|
||||
|
||||
async start(options: StartOptions = {}) {
|
||||
if (this._started) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._started = true;
|
||||
|
||||
if (options.checkInstall && !(await this.isInstalled())) {
|
||||
throw new ApplicationNotInstall(
|
||||
`Application ${this.name} is not installed, Please run 'yarn nocobase install' command first`,
|
||||
);
|
||||
}
|
||||
|
||||
this.setMaintainingMessage('starting app...');
|
||||
|
||||
if (this.db.closed()) {
|
||||
await this.db.reconnect();
|
||||
}
|
||||
|
||||
if (options.dbSync) {
|
||||
console.log('db sync...');
|
||||
await this.db.sync();
|
||||
}
|
||||
|
||||
this.setMaintainingMessage('emit beforeStart');
|
||||
await this.emitAsync('beforeStart', this, options);
|
||||
|
||||
if (options?.listen?.port) {
|
||||
const pmServer = await this.pm.listen();
|
||||
|
||||
const listen = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const Server = this.listen(options?.listen, () => {
|
||||
resolve(Server);
|
||||
});
|
||||
|
||||
Server.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
Server.on('close', () => {
|
||||
pmServer.close();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
//@ts-ignore
|
||||
this.listenServer = await listen();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
this.setMaintainingMessage('emit afterStart');
|
||||
await this.emitAsync('afterStart', this, options);
|
||||
await this.emitAsync('__started', this, options);
|
||||
this.stopped = false;
|
||||
}
|
||||
|
||||
listen(...args): Server {
|
||||
return this.appManager.listen(...args);
|
||||
async isStarted() {
|
||||
return this._started;
|
||||
}
|
||||
|
||||
async tryReloadOrRestart() {
|
||||
if (this._started) {
|
||||
await this.restart();
|
||||
} else {
|
||||
await this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async restart(options: StartOptions = {}) {
|
||||
if (!this._started) {
|
||||
return;
|
||||
}
|
||||
this._started = false;
|
||||
await this.reload(options);
|
||||
await this.start(options);
|
||||
this.emit('__restarted', this, options);
|
||||
}
|
||||
|
||||
async stop(options: any = {}) {
|
||||
this.log.debug('stop app...');
|
||||
this.setMaintainingMessage('stopping app...');
|
||||
if (this.stopped) {
|
||||
this.log.warn(`Application ${this.name} already stopped`);
|
||||
return;
|
||||
@ -489,31 +475,33 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
|
||||
await this.emitAsync('beforeStop', this, options);
|
||||
|
||||
// close http server
|
||||
if (this.listenServer) {
|
||||
await promisify(this.listenServer.close).call(this.listenServer);
|
||||
this.listenServer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
// close database connection
|
||||
// silent if database already closed
|
||||
if (!this.db.closed()) {
|
||||
this.logger.info(`close db`);
|
||||
await this.db.close();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
this.log.error(e);
|
||||
}
|
||||
|
||||
await this.emitAsync('afterStop', this, options);
|
||||
this.stopped = true;
|
||||
console.log(`${this.name} is stopped`);
|
||||
this.log.info(`${this.name} is stopped`);
|
||||
this._started = false;
|
||||
}
|
||||
|
||||
async destroy(options: any = {}) {
|
||||
this.logger.debug('start destroy app');
|
||||
this.setMaintainingMessage('destroying app...');
|
||||
await this.emitAsync('beforeDestroy', this, options);
|
||||
await this.stop(options);
|
||||
|
||||
this.logger.debug('emit afterDestroy');
|
||||
await this.emitAsync('afterDestroy', this, options);
|
||||
|
||||
this.logger.debug('finish destroy app');
|
||||
}
|
||||
|
||||
async dbVersionCheck(options?: { exit?: boolean }) {
|
||||
@ -554,19 +542,37 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
}
|
||||
|
||||
async install(options: InstallOptions = {}) {
|
||||
console.log('Database dialect: ' + this.db.sequelize.getDialect());
|
||||
this.setMaintainingMessage('installing app...');
|
||||
this.log.debug('Database dialect: ' + this.db.sequelize.getDialect());
|
||||
|
||||
if (options?.clean || options?.sync?.force) {
|
||||
console.log('Truncate database and reload app configuration');
|
||||
this.log.debug('truncate database');
|
||||
await this.db.clean({ drop: true });
|
||||
await this.reload({ method: 'install' });
|
||||
this.log.debug('app reloading');
|
||||
await this.reload();
|
||||
} else if (await this.isInstalled()) {
|
||||
this.log.warn('app is installed');
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.debug('emit beforeInstall');
|
||||
this.setMaintainingMessage('call beforeInstall hook...');
|
||||
await this.emitAsync('beforeInstall', this, options);
|
||||
await this.db.sync();
|
||||
this.log.debug('start install plugins');
|
||||
await this.pm.install(options);
|
||||
this.log.debug('update version');
|
||||
await this.version.update();
|
||||
this.log.debug('emit afterInstall');
|
||||
this.setMaintainingMessage('call afterInstall hook...');
|
||||
await this.emitAsync('afterInstall', this, options);
|
||||
|
||||
if (this._maintainingStatusBeforeCommand?.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._started) {
|
||||
await this.restart();
|
||||
}
|
||||
}
|
||||
|
||||
async upgrade(options: any = {}) {
|
||||
@ -581,15 +587,113 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
});
|
||||
await this.version.update();
|
||||
await this.emitAsync('afterUpgrade', this, options);
|
||||
this.log.debug(chalk.green(`✨ NocoBase has been upgraded to v${this.getVersion()}`));
|
||||
if (this._started) {
|
||||
await this.restart();
|
||||
}
|
||||
}
|
||||
|
||||
declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
appName: this.name,
|
||||
name: this.name,
|
||||
};
|
||||
}
|
||||
|
||||
reInitEvents() {
|
||||
for (const eventName of this.eventNames()) {
|
||||
for (const listener of this.listeners(eventName)) {
|
||||
if (listener['_reinitializable']) {
|
||||
this.removeListener(eventName, listener as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected init() {
|
||||
const options = this.options;
|
||||
|
||||
const logger = createAppLogger({
|
||||
...options.logger,
|
||||
defaultMeta: {
|
||||
app: this.name,
|
||||
},
|
||||
});
|
||||
|
||||
this._logger = logger.instance;
|
||||
|
||||
this.reInitEvents();
|
||||
|
||||
this.middleware = new Toposort<any>();
|
||||
this.plugins = new Map<string, Plugin>();
|
||||
this._acl = createACL();
|
||||
|
||||
this.use(logger.middleware, { tag: 'logger' });
|
||||
|
||||
if (this._db) {
|
||||
// MaxListenersExceededWarning
|
||||
this._db.removeAllListeners();
|
||||
}
|
||||
|
||||
this._db = this.createDatabase(options);
|
||||
|
||||
this._resourcer = createResourcer(options);
|
||||
this._cli = this.createCli();
|
||||
this._i18n = createI18n(options);
|
||||
this._cache = createCache(options.cache);
|
||||
this.context.db = this._db;
|
||||
this.context.logger = this._logger;
|
||||
this.context.resourcer = this._resourcer;
|
||||
this.context.cache = this._cache;
|
||||
|
||||
const plugins = this._pm ? this._pm.options.plugins : options.plugins;
|
||||
|
||||
this._pm = new PluginManager({
|
||||
app: this,
|
||||
plugins: plugins || [],
|
||||
});
|
||||
|
||||
this._authManager = new AuthManager({
|
||||
authKey: 'X-Authenticator',
|
||||
default: 'basic',
|
||||
});
|
||||
|
||||
this.resource({
|
||||
name: 'auth',
|
||||
actions: authActions,
|
||||
});
|
||||
|
||||
this._resourcer.use(this._authManager.middleware(), { tag: 'auth' });
|
||||
|
||||
if (this.options.acl !== false) {
|
||||
this._resourcer.use(this._acl.middleware(), { tag: 'acl', after: ['auth'] });
|
||||
}
|
||||
|
||||
this._locales = new Locale(createAppProxy(this));
|
||||
|
||||
registerMiddlewares(this, options);
|
||||
|
||||
if (options.registerActions !== false) {
|
||||
registerActions(this);
|
||||
}
|
||||
|
||||
registerCli(this);
|
||||
|
||||
this._version = new ApplicationVersion(this);
|
||||
}
|
||||
|
||||
private createDatabase(options: ApplicationOptions) {
|
||||
const db = new Database({
|
||||
...(options.database instanceof Database ? options.database.options : options.database),
|
||||
migrator: {
|
||||
context: { app: this },
|
||||
},
|
||||
});
|
||||
|
||||
db.setLogger(this._logger);
|
||||
|
||||
return db;
|
||||
}
|
||||
}
|
||||
|
||||
applyMixins(Application, [AsyncEmitter]);
|
||||
|
@ -11,8 +11,5 @@ export default (app: Application) => {
|
||||
drop: force,
|
||||
},
|
||||
});
|
||||
await app.stop({
|
||||
cliArgs,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
9
packages/core/server/src/commands/destroy.ts
Normal file
9
packages/core/server/src/commands/destroy.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import Application from '../application';
|
||||
|
||||
export default (app: Application) => {
|
||||
app.command('destroy').action(async (...cliArgs) => {
|
||||
await app.destroy({
|
||||
cliArgs,
|
||||
});
|
||||
});
|
||||
};
|
@ -8,6 +8,9 @@ export function registerCli(app: Application) {
|
||||
require('./install').default(app);
|
||||
require('./migrator').default(app);
|
||||
require('./start').default(app);
|
||||
require('./restart').default(app);
|
||||
require('./stop').default(app);
|
||||
require('./destroy').default(app);
|
||||
require('./upgrade').default(app);
|
||||
require('./pm').default(app);
|
||||
|
||||
|
@ -1,4 +1,3 @@
|
||||
import chalk from 'chalk';
|
||||
import Application from '../application';
|
||||
|
||||
export default (app: Application) => {
|
||||
@ -6,40 +5,8 @@ export default (app: Application) => {
|
||||
.command('install')
|
||||
.option('-f, --force')
|
||||
.option('-c, --clean')
|
||||
.option('-s, --silent')
|
||||
.option('-r, --retry [retry]')
|
||||
.option('-I, --ignore-installed')
|
||||
.action(async (...cliArgs) => {
|
||||
let installed = false;
|
||||
const [opts] = cliArgs;
|
||||
|
||||
if (opts.ignoreInstalled) {
|
||||
if (await app.isInstalled()) {
|
||||
console.log('Application installed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts?.clean && !opts?.force) {
|
||||
if (await app.isInstalled()) {
|
||||
installed = true;
|
||||
if (!opts.silent) {
|
||||
console.log('NocoBase is already installed. To reinstall, please execute:');
|
||||
console.log();
|
||||
const command = '$ yarn nocobase install -f';
|
||||
console.log(chalk.yellow(command));
|
||||
console.log();
|
||||
console.log(chalk.red('This operation will clear the database!!!'));
|
||||
console.log();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.silent || !installed) {
|
||||
console.log(`Start installing NocoBase`);
|
||||
}
|
||||
|
||||
await app.install({
|
||||
cliArgs,
|
||||
clean: opts.clean,
|
||||
@ -47,9 +14,5 @@ export default (app: Application) => {
|
||||
force: opts.force,
|
||||
},
|
||||
});
|
||||
|
||||
await app.stop({
|
||||
cliArgs,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -1,18 +1,51 @@
|
||||
import Application from '../application';
|
||||
|
||||
export default (app: Application) => {
|
||||
app
|
||||
.command('pm')
|
||||
.argument('<method>')
|
||||
.arguments('<plugins...>')
|
||||
.option('-S, --skip-yarn-install', 'skip yarn install')
|
||||
.action(async (method, plugins, options, ...args) => {
|
||||
if (method === 'add' && !options.skipYarnInstall) {
|
||||
const { run } = require('@nocobase/cli/src/util');
|
||||
console.log('Install dependencies and rebuild workspaces');
|
||||
await run('yarn', ['install']);
|
||||
}
|
||||
const pm = app.command('pm');
|
||||
|
||||
app.pm.clientWrite({ method, plugins });
|
||||
pm.command('create')
|
||||
.arguments('plugin')
|
||||
.action(async (plugin) => {
|
||||
await app.pm.create(plugin);
|
||||
});
|
||||
|
||||
pm.command('add')
|
||||
.arguments('plugin')
|
||||
.action(async (plugin) => {
|
||||
await app.pm.add(plugin);
|
||||
});
|
||||
|
||||
pm.command('enable')
|
||||
.arguments('<plugins...>')
|
||||
.action(async (plugins) => {
|
||||
try {
|
||||
await app.pm.enable(plugins);
|
||||
} catch (error) {
|
||||
app.log.debug(`Failed to enable plugin: ${error.message}`);
|
||||
app.setMaintainingMessage(`Failed to enable plugin: ${error.message}`);
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(() => resolve(null), 10000);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
pm.command('disable')
|
||||
.arguments('<plugins...>')
|
||||
.action(async (plugins) => {
|
||||
try {
|
||||
await app.pm.disable(plugins);
|
||||
} catch (error) {
|
||||
app.log.debug(`Failed to disable plugin: ${error.message}`);
|
||||
app.setMaintainingMessage(`Failed to disable plugin: ${error.message}`);
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(() => resolve(null), 10000);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
pm.command('remove')
|
||||
.arguments('<plugins...>')
|
||||
.action(async (plugins) => {
|
||||
await app.pm.remove(plugins);
|
||||
});
|
||||
};
|
||||
|
9
packages/core/server/src/commands/restart.ts
Normal file
9
packages/core/server/src/commands/restart.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import Application from '../application';
|
||||
|
||||
export default (app: Application) => {
|
||||
app.command('restart').action(async (...cliArgs) => {
|
||||
await app.restart({
|
||||
cliArgs,
|
||||
});
|
||||
});
|
||||
};
|
@ -3,26 +3,24 @@ import Application from '../application';
|
||||
export default (app: Application) => {
|
||||
app
|
||||
.command('start')
|
||||
.option('-s, --silent')
|
||||
.option('-p, --port [post]')
|
||||
.option('-h, --host [host]')
|
||||
.option('--db-sync')
|
||||
.option('--quickstart')
|
||||
.action(async (...cliArgs) => {
|
||||
const [opts] = cliArgs;
|
||||
const port = opts.port || process.env.APP_PORT || 13000;
|
||||
const host = opts.host || process.env.APP_HOST || '0.0.0.0';
|
||||
|
||||
if (opts.quickstart) {
|
||||
if (await app.isInstalled()) {
|
||||
app.log.debug('installed....');
|
||||
await app.upgrade();
|
||||
} else {
|
||||
await app.install();
|
||||
}
|
||||
}
|
||||
|
||||
await app.start({
|
||||
dbSync: opts?.dbSync,
|
||||
cliArgs,
|
||||
listen: {
|
||||
port,
|
||||
host,
|
||||
},
|
||||
checkInstall: true,
|
||||
});
|
||||
|
||||
if (!opts.silent) {
|
||||
console.log(`🚀 NocoBase server running at: http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
9
packages/core/server/src/commands/stop.ts
Normal file
9
packages/core/server/src/commands/stop.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import Application from '../application';
|
||||
|
||||
export default (app: Application) => {
|
||||
app.command('stop').action(async (...cliArgs) => {
|
||||
await app.stop({
|
||||
cliArgs,
|
||||
});
|
||||
});
|
||||
};
|
@ -9,9 +9,6 @@ export default (app: Application) => {
|
||||
const [opts] = cliArgs;
|
||||
console.log('upgrading...');
|
||||
await app.upgrade();
|
||||
await app.stop({
|
||||
cliArgs,
|
||||
});
|
||||
console.log(chalk.green(`✨ NocoBase has been upgraded to v${app.getVersion()}`));
|
||||
});
|
||||
};
|
||||
|
@ -0,0 +1,9 @@
|
||||
export class ApplicationNotInstall extends Error {
|
||||
code: string;
|
||||
|
||||
constructor(message) {
|
||||
super(message);
|
||||
|
||||
this.code = 'APP_NOT_INSTALLED_ERROR';
|
||||
}
|
||||
}
|
127
packages/core/server/src/gateway/errors.ts
Normal file
127
packages/core/server/src/gateway/errors.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { AppSupervisor } from '../app-supervisor';
|
||||
import lodash from 'lodash';
|
||||
|
||||
interface AppError {
|
||||
status: number;
|
||||
message: any;
|
||||
command?: any;
|
||||
maintaining: boolean;
|
||||
code: any;
|
||||
}
|
||||
|
||||
interface AppErrors {
|
||||
[key: string]: Omit<AppError, 'code'> & {
|
||||
code?: any;
|
||||
};
|
||||
}
|
||||
|
||||
export const errors: AppErrors = {
|
||||
APP_NOT_FOUND: {
|
||||
status: 404,
|
||||
message: ({ appName }) => `application ${appName} not found`,
|
||||
maintaining: true,
|
||||
},
|
||||
|
||||
APP_ERROR: {
|
||||
status: 503,
|
||||
message: ({ app }) => {
|
||||
return AppSupervisor.getInstance().appErrors[app.name]?.message;
|
||||
},
|
||||
code: ({ app }): string => {
|
||||
const error = AppSupervisor.getInstance().appErrors[app.name];
|
||||
return error['code'] || 'APP_ERROR';
|
||||
},
|
||||
command: ({ app }) => app.getMaintaining().command,
|
||||
maintaining: true,
|
||||
},
|
||||
|
||||
APP_STARTING: {
|
||||
status: 503,
|
||||
message: ({ app }) => app.maintainingMessage,
|
||||
maintaining: true,
|
||||
},
|
||||
|
||||
APP_STOPPED: {
|
||||
status: 503,
|
||||
message: ({ app }) => `application ${app.name} is stopped`,
|
||||
maintaining: true,
|
||||
},
|
||||
|
||||
APP_INITIALIZED: {
|
||||
status: 503,
|
||||
message: ({ app }) => `application ${app.name} is initialized, waiting for command`,
|
||||
maintaining: true,
|
||||
},
|
||||
|
||||
APP_INITIALIZING: {
|
||||
status: 503,
|
||||
message: ({ appName }) => `application ${appName} is initializing`,
|
||||
maintaining: true,
|
||||
},
|
||||
|
||||
COMMAND_ERROR: {
|
||||
status: 503,
|
||||
maintaining: true,
|
||||
message: ({ app }) => app.getMaintaining().error.message,
|
||||
command: ({ app }) => app.getMaintaining().command,
|
||||
},
|
||||
|
||||
COMMAND_END: {
|
||||
status: 503,
|
||||
maintaining: true,
|
||||
message: ({ app }) => `${app.getMaintaining().command.name} running end`,
|
||||
command: ({ app }) => app.getMaintaining().command,
|
||||
},
|
||||
|
||||
APP_COMMANDING: {
|
||||
status: 503,
|
||||
maintaining: true,
|
||||
message: ({ app, message }) => message || app.maintainingMessage,
|
||||
command: ({ app, command }) => command || app.getMaintaining().command,
|
||||
},
|
||||
|
||||
APP_RUNNING: {
|
||||
status: 200,
|
||||
maintaining: false,
|
||||
message: ({ message, app }) => message || `application ${app.name} is running`,
|
||||
},
|
||||
|
||||
UNKNOWN_ERROR: {
|
||||
status: 500,
|
||||
message: 'unknown error',
|
||||
maintaining: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function getErrorWithCode(errorCode: string): AppError {
|
||||
const rawCode = errorCode;
|
||||
errorCode = lodash.snakeCase(errorCode).toUpperCase();
|
||||
|
||||
if (!errors[errorCode] && errors[`APP_${errorCode}`]) {
|
||||
errorCode = `APP_${errorCode}`;
|
||||
}
|
||||
|
||||
if (!errors[errorCode]) {
|
||||
errorCode = 'UNKNOWN_ERROR';
|
||||
}
|
||||
|
||||
const error = lodash.cloneDeep(errors[errorCode]);
|
||||
|
||||
if (!error.code) {
|
||||
error['code'] = errorCode == 'UNKNOWN_ERROR' ? rawCode : errorCode;
|
||||
}
|
||||
|
||||
return error as AppError;
|
||||
}
|
||||
|
||||
export function applyErrorWithArgs(error: AppError, options) {
|
||||
const functionKeys = Object.keys(error).filter((key) => typeof error[key] === 'function');
|
||||
const functionResults = functionKeys.map((key) => {
|
||||
return error[key](options);
|
||||
});
|
||||
|
||||
return {
|
||||
...error,
|
||||
...lodash.zipObject(functionKeys, functionResults),
|
||||
};
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const cwd = process.cwd();
|
||||
const NODE_MODULES = path.join(cwd, 'node_modules');
|
||||
|
||||
const PREFIX = '/api/plugins/client/';
|
||||
|
||||
const isMatchClientStaticUrl = (url: string) => {
|
||||
return url.startsWith(PREFIX);
|
||||
};
|
||||
|
||||
/**
|
||||
* get package name from url
|
||||
*
|
||||
* @example
|
||||
* /api/plugins/client/@nocobase/plugin-acl/index.js => @nocobase/plugin-acl
|
||||
* /api/plugins/client/my-plugin/README.md => my-plugin
|
||||
*/
|
||||
const getPackageName = (url: string) => {
|
||||
const urlArr = url.split('/');
|
||||
return urlArr[4].startsWith('@') ? `${urlArr[4]}/${urlArr[5]}` : urlArr[4];
|
||||
};
|
||||
|
||||
/**
|
||||
* get plugin client static file real path
|
||||
*
|
||||
* @example
|
||||
* /api/plugins/client/@nocobase/plugin-acl/index.js => /node_modules/@nocobase/plugin-acl/dist/client/index.js
|
||||
* /api/plugins/client/my-plugin/README.md => /node_modules/my-plugin/dist/client/README.md
|
||||
*/
|
||||
const getRealPath = (packageName: string, url: string) => {
|
||||
const ext = path.extname(url);
|
||||
const filePath = url.replace(`${PREFIX}${packageName}/`, '');
|
||||
if (ext.toLowerCase() === '.md') {
|
||||
return path.join(NODE_MODULES, packageName, filePath);
|
||||
} else {
|
||||
return path.join(NODE_MODULES, packageName, 'dist', 'client', filePath);
|
||||
}
|
||||
};
|
||||
|
||||
export async function handlePluginStaticFile(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
||||
if (isMatchClientStaticUrl(req.url)) {
|
||||
// TODO: check packageName in plugins
|
||||
const packageName = getPackageName(req.url);
|
||||
|
||||
const realPath = getRealPath(packageName, req.url);
|
||||
|
||||
try {
|
||||
// get file stats
|
||||
const stats = await fs.promises.stat(realPath);
|
||||
|
||||
const ifModifiedSince = req.headers['if-modified-since'];
|
||||
|
||||
const lastModified = stats.mtime.toUTCString();
|
||||
|
||||
// check cache headers
|
||||
if (ifModifiedSince === lastModified) {
|
||||
res.statusCode = 304;
|
||||
return true;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(cwd, realPath);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Length': stats.size,
|
||||
});
|
||||
|
||||
const readStream = fs.createReadStream(relativePath);
|
||||
readStream.pipe(res);
|
||||
} catch (e) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
310
packages/core/server/src/gateway/index.ts
Normal file
310
packages/core/server/src/gateway/index.ts
Normal file
@ -0,0 +1,310 @@
|
||||
import { Command } from 'commander';
|
||||
import compression from 'compression';
|
||||
import { EventEmitter } from 'events';
|
||||
import http, { IncomingMessage, ServerResponse } from 'http';
|
||||
import { promisify } from 'node:util';
|
||||
import { resolve } from 'path';
|
||||
import qs from 'qs';
|
||||
import handler from 'serve-handler';
|
||||
import { parse } from 'url';
|
||||
import { AppSupervisor } from '../app-supervisor';
|
||||
import { ApplicationOptions } from '../application';
|
||||
import { applyErrorWithArgs, getErrorWithCode } from './errors';
|
||||
import { IPCSocketClient } from './ipc-socket-client';
|
||||
import { IPCSocketServer } from './ipc-socket-server';
|
||||
import { WSServer } from './ws-server';
|
||||
|
||||
const compress = promisify(compression());
|
||||
|
||||
export interface IncomingRequest {
|
||||
url: string;
|
||||
headers: any;
|
||||
}
|
||||
|
||||
export type AppSelector = (req: IncomingRequest) => string | Promise<string>;
|
||||
|
||||
interface StartHttpServerOptions {
|
||||
port: number;
|
||||
host: string;
|
||||
callback?: (server: http.Server) => void;
|
||||
}
|
||||
|
||||
interface RunOptions {
|
||||
mainAppOptions: ApplicationOptions;
|
||||
}
|
||||
|
||||
export class Gateway extends EventEmitter {
|
||||
private static instance: Gateway;
|
||||
/**
|
||||
* use main app as default app to handle request
|
||||
*/
|
||||
appSelector: AppSelector;
|
||||
public server: http.Server | null = null;
|
||||
public ipcSocketServer: IPCSocketServer | null = null;
|
||||
private port: number = process.env.APP_PORT ? parseInt(process.env.APP_PORT) : null;
|
||||
private host = '0.0.0.0';
|
||||
private wsServer: WSServer;
|
||||
private socketPath = resolve(process.cwd(), 'storage', 'gateway.sock');
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
this.reset();
|
||||
}
|
||||
|
||||
public static getInstance(options: any = {}): Gateway {
|
||||
if (!Gateway.instance) {
|
||||
Gateway.instance = new Gateway();
|
||||
}
|
||||
|
||||
return Gateway.instance;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.reset();
|
||||
Gateway.instance = null;
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this.setAppSelector(async (req) => {
|
||||
const appName = qs.parse(parse(req.url).query)?.__appName;
|
||||
if (appName) {
|
||||
return appName;
|
||||
}
|
||||
|
||||
if (req.headers['x-app']) {
|
||||
return req.headers['x-app'];
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
if (this.ipcSocketServer) {
|
||||
this.ipcSocketServer.close();
|
||||
this.ipcSocketServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
setAppSelector(selector: AppSelector) {
|
||||
this.appSelector = selector;
|
||||
this.emit('appSelectorChanged');
|
||||
}
|
||||
|
||||
responseError(
|
||||
res: ServerResponse,
|
||||
error: {
|
||||
status: number;
|
||||
maintaining: boolean;
|
||||
message: string;
|
||||
code: string;
|
||||
},
|
||||
) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.statusCode = error.status;
|
||||
res.end(JSON.stringify({ error }));
|
||||
}
|
||||
|
||||
responseErrorWithCode(code, res, options) {
|
||||
this.responseError(res, applyErrorWithArgs(getErrorWithCode(code), options));
|
||||
}
|
||||
|
||||
async requestHandler(req: IncomingMessage, res: ServerResponse) {
|
||||
const { pathname } = parse(req.url);
|
||||
|
||||
if (pathname.startsWith('/storage/uploads/')) {
|
||||
await compress(req, res);
|
||||
return handler(req, res, {
|
||||
public: resolve(process.cwd()),
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/plugins/client/')) {
|
||||
await compress(req, res);
|
||||
return handler(req, res, {
|
||||
public: resolve(process.cwd(), 'node_modules'),
|
||||
rewrites: [
|
||||
{
|
||||
source: '/api/plugins/client/:plugin/index.js',
|
||||
destination: '/:plugin/dist/client/index.js',
|
||||
},
|
||||
{
|
||||
source: '/api/plugins/client/@:org/:plugin/index.js',
|
||||
destination: '/@:org/:plugin/dist/client/index.js',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (!pathname.startsWith('/api')) {
|
||||
await compress(req, res);
|
||||
return handler(req, res, {
|
||||
public: `${process.env.APP_PACKAGE_ROOT}/dist/client`,
|
||||
rewrites: [{ source: '/**', destination: '/index.html' }],
|
||||
});
|
||||
}
|
||||
|
||||
const handleApp = await this.getRequestHandleAppName(req as IncomingRequest);
|
||||
|
||||
const hasApp = AppSupervisor.getInstance().hasApp(handleApp);
|
||||
|
||||
if (!hasApp) {
|
||||
AppSupervisor.getInstance().bootStrapApp(handleApp);
|
||||
}
|
||||
|
||||
const appStatus = AppSupervisor.getInstance().getAppStatus(handleApp, 'initializing');
|
||||
|
||||
if (appStatus === 'not_found') {
|
||||
this.responseErrorWithCode('APP_NOT_FOUND', res, { appName: handleApp });
|
||||
return;
|
||||
}
|
||||
|
||||
if (appStatus === 'initializing') {
|
||||
this.responseErrorWithCode('APP_INITIALIZING', res, { appName: handleApp });
|
||||
return;
|
||||
}
|
||||
|
||||
const app = await AppSupervisor.getInstance().getApp(handleApp);
|
||||
|
||||
if (appStatus !== 'running') {
|
||||
this.responseErrorWithCode(`${appStatus}`, res, { app });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url.endsWith('/__health_check')) {
|
||||
res.statusCode = 200;
|
||||
res.end('ok');
|
||||
return;
|
||||
}
|
||||
|
||||
app.callback()(req, res);
|
||||
}
|
||||
|
||||
async getRequestHandleAppName(req: IncomingRequest) {
|
||||
return (await this.appSelector(req)) || 'main';
|
||||
}
|
||||
|
||||
getCallback() {
|
||||
return this.requestHandler.bind(this);
|
||||
}
|
||||
|
||||
async run(options: RunOptions) {
|
||||
const isStart = this.isStart();
|
||||
if (isStart) {
|
||||
const startOptions = this.getStartOptions();
|
||||
const port = startOptions.port || process.env.APP_PORT || 13000;
|
||||
const host = startOptions.host || process.env.APP_HOST || '0.0.0.0';
|
||||
|
||||
this.start({
|
||||
port,
|
||||
host,
|
||||
});
|
||||
} else if (!this.isHelp()) {
|
||||
const ipcClient = await this.tryConnectToIPCServer();
|
||||
|
||||
if (ipcClient) {
|
||||
ipcClient.write({ type: 'passCliArgv', payload: { argv: process.argv } });
|
||||
ipcClient.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const mainApp = AppSupervisor.getInstance().bootMainApp(options.mainAppOptions);
|
||||
mainApp.runAsCLI();
|
||||
}
|
||||
|
||||
isStart() {
|
||||
const argv = process.argv;
|
||||
return argv[2] === 'start';
|
||||
}
|
||||
|
||||
isHelp() {
|
||||
const argv = process.argv;
|
||||
return argv[2] === 'help';
|
||||
}
|
||||
|
||||
getStartOptions() {
|
||||
const argv = process.argv;
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.allowUnknownOption()
|
||||
.option('-s, --silent')
|
||||
.option('-p, --port [post]')
|
||||
.option('-h, --host [host]')
|
||||
.option('--db-sync')
|
||||
.parse(process.argv);
|
||||
const options = program.opts();
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
start(options: StartHttpServerOptions) {
|
||||
this.startHttpServer(options);
|
||||
this.startIPCSocketServer();
|
||||
}
|
||||
|
||||
startIPCSocketServer() {
|
||||
this.ipcSocketServer = IPCSocketServer.buildServer(this.socketPath);
|
||||
}
|
||||
|
||||
startHttpServer(options: StartHttpServerOptions) {
|
||||
if (options?.port !== null) {
|
||||
this.port = options.port;
|
||||
}
|
||||
|
||||
if (options?.host) {
|
||||
this.host = options.host;
|
||||
}
|
||||
|
||||
if (this.port === null) {
|
||||
console.log('gateway port is not set, http server will not start');
|
||||
return;
|
||||
}
|
||||
|
||||
this.server = http.createServer(this.getCallback());
|
||||
|
||||
this.wsServer = new WSServer();
|
||||
|
||||
this.server.on('upgrade', (request, socket, head) => {
|
||||
const { pathname } = parse(request.url);
|
||||
|
||||
if (pathname === '/ws') {
|
||||
this.wsServer.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.wsServer.wss.emit('connection', ws, request);
|
||||
});
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(this.port, this.host, () => {
|
||||
console.log(`Gateway HTTP Server running at http://${this.host}:${this.port}/`);
|
||||
if (options?.callback) {
|
||||
options.callback(this.server);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async tryConnectToIPCServer() {
|
||||
try {
|
||||
const ipcClient = await this.getIPCSocketClient();
|
||||
return ipcClient;
|
||||
} catch (e) {
|
||||
// console.log(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getIPCSocketClient() {
|
||||
return await IPCSocketClient.getConnection(this.socketPath);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.server?.close();
|
||||
this.wsServer?.close();
|
||||
}
|
||||
}
|
34
packages/core/server/src/gateway/ipc-socket-client.ts
Normal file
34
packages/core/server/src/gateway/ipc-socket-client.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import net from 'net';
|
||||
|
||||
const writeJSON = (socket: net.Socket, data: object) => {
|
||||
socket.write(JSON.stringify(data) + '\n', 'utf8');
|
||||
};
|
||||
|
||||
export class IPCSocketClient {
|
||||
client: net.Socket;
|
||||
|
||||
constructor(client: net.Socket) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
static async getConnection(serverPath: string) {
|
||||
return new Promise<IPCSocketClient>((resolve, reject) => {
|
||||
const client = net.createConnection({ path: serverPath }, () => {
|
||||
// 'connect' listener.
|
||||
console.log('connected to server!');
|
||||
resolve(new IPCSocketClient(client));
|
||||
});
|
||||
client.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.client.end();
|
||||
}
|
||||
|
||||
write(data: any) {
|
||||
writeJSON(this.client, data);
|
||||
}
|
||||
}
|
63
packages/core/server/src/gateway/ipc-socket-server.ts
Normal file
63
packages/core/server/src/gateway/ipc-socket-server.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import net from 'net';
|
||||
import fs from 'fs';
|
||||
import { Gateway } from '../gateway';
|
||||
import { AppSupervisor } from '../app-supervisor';
|
||||
|
||||
export class IPCSocketServer {
|
||||
socketServer: net.Server;
|
||||
|
||||
constructor(server: net.Server) {
|
||||
this.socketServer = server;
|
||||
}
|
||||
|
||||
static buildServer(socketPath: string) {
|
||||
// try to unlink the socket from a previous run
|
||||
if (fs.existsSync(socketPath)) {
|
||||
fs.unlinkSync(socketPath);
|
||||
}
|
||||
|
||||
const socketServer = net.createServer((c) => {
|
||||
console.log('client connected');
|
||||
|
||||
c.on('end', () => {
|
||||
console.log('client disconnected');
|
||||
});
|
||||
|
||||
c.on('data', (data) => {
|
||||
const dataAsString = data.toString();
|
||||
const messages = dataAsString.split('\n');
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dataObj = JSON.parse(message);
|
||||
|
||||
IPCSocketServer.handleClientMessage(dataObj);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socketServer.listen(socketPath, () => {
|
||||
console.log(`Gateway IPC Server running at ${socketPath}`);
|
||||
});
|
||||
|
||||
return new IPCSocketServer(socketServer);
|
||||
}
|
||||
|
||||
static async handleClientMessage({ type, payload }) {
|
||||
console.log(`cli received message ${type}`);
|
||||
|
||||
if (type === 'passCliArgv') {
|
||||
const argv = payload.argv;
|
||||
|
||||
const mainApp = await AppSupervisor.getInstance().getApp('main');
|
||||
mainApp.runAsCLI(argv);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socketServer.close();
|
||||
}
|
||||
}
|
181
packages/core/server/src/gateway/ws-server.ts
Normal file
181
packages/core/server/src/gateway/ws-server.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { Gateway, IncomingRequest } from '../gateway';
|
||||
import WebSocket from 'ws';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { AppSupervisor } from '../app-supervisor';
|
||||
import { applyErrorWithArgs, getErrorWithCode } from './errors';
|
||||
import lodash from 'lodash';
|
||||
|
||||
declare class WebSocketWithId extends WebSocket {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface WebSocketClient {
|
||||
ws: WebSocketWithId;
|
||||
tags: string[];
|
||||
url: string;
|
||||
headers: any;
|
||||
app?: string;
|
||||
}
|
||||
|
||||
function getPayloadByErrorCode(code, options) {
|
||||
const error = getErrorWithCode(code);
|
||||
return lodash.omit(applyErrorWithArgs(error, options), ['status', 'maintaining']);
|
||||
}
|
||||
|
||||
export class WSServer {
|
||||
wss: WebSocket.Server;
|
||||
webSocketClients = new Map<string, WebSocketClient>();
|
||||
|
||||
constructor() {
|
||||
this.wss = new WebSocket.Server({ noServer: true });
|
||||
|
||||
this.wss.on('connection', (ws: WebSocketWithId, request: IncomingMessage) => {
|
||||
const client = this.addNewConnection(ws, request);
|
||||
|
||||
console.log(`new client connected ${ws.id}`);
|
||||
|
||||
ws.on('error', () => {
|
||||
this.removeConnection(ws.id);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
this.removeConnection(ws.id);
|
||||
});
|
||||
});
|
||||
|
||||
Gateway.getInstance().on('appSelectorChanged', () => {
|
||||
// reset connection app tags
|
||||
this.loopThroughConnections(async (client) => {
|
||||
const handleAppName = await Gateway.getInstance().getRequestHandleAppName({
|
||||
url: client.url,
|
||||
headers: client.headers,
|
||||
});
|
||||
|
||||
client.tags = client.tags.filter((tag) => !tag.startsWith('app#'));
|
||||
client.tags.push(`app#${handleAppName}`);
|
||||
|
||||
AppSupervisor.getInstance().bootStrapApp(handleAppName);
|
||||
});
|
||||
});
|
||||
|
||||
AppSupervisor.getInstance().on('appMaintainingMessageChanged', async ({ appName, message, command, status }) => {
|
||||
const app = await AppSupervisor.getInstance().getApp(appName, {
|
||||
withOutBootStrap: true,
|
||||
});
|
||||
|
||||
const payload = getPayloadByErrorCode(status, {
|
||||
app,
|
||||
message,
|
||||
command,
|
||||
});
|
||||
|
||||
this.sendToConnectionsByTag('app', appName, {
|
||||
type: 'maintaining',
|
||||
payload,
|
||||
});
|
||||
});
|
||||
|
||||
AppSupervisor.getInstance().on('appStatusChanged', async ({ appName, status }) => {
|
||||
const app = await AppSupervisor.getInstance().getApp(appName, {
|
||||
withOutBootStrap: true,
|
||||
});
|
||||
|
||||
const payload = getPayloadByErrorCode(status, { app, appName });
|
||||
console.log(`send payload ${JSON.stringify(payload)}`);
|
||||
this.sendToConnectionsByTag('app', appName, {
|
||||
type: 'maintaining',
|
||||
payload,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addNewConnection(ws: WebSocketWithId, request: IncomingMessage) {
|
||||
const id = nanoid();
|
||||
|
||||
ws.id = id;
|
||||
|
||||
this.webSocketClients.set(id, {
|
||||
ws,
|
||||
tags: [],
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
this.setClientApp(this.webSocketClients.get(id));
|
||||
|
||||
return this.webSocketClients.get(id);
|
||||
}
|
||||
|
||||
async setClientApp(client: WebSocketClient) {
|
||||
const req: IncomingRequest = {
|
||||
url: client.url,
|
||||
headers: client.headers,
|
||||
};
|
||||
|
||||
const handleAppName = await Gateway.getInstance().getRequestHandleAppName(req);
|
||||
|
||||
client.app = handleAppName;
|
||||
console.log(`client tags: app#${handleAppName}`);
|
||||
client.tags.push(`app#${handleAppName}`);
|
||||
|
||||
const hasApp = AppSupervisor.getInstance().hasApp(handleAppName);
|
||||
|
||||
if (!hasApp) {
|
||||
AppSupervisor.getInstance().bootStrapApp(handleAppName);
|
||||
}
|
||||
|
||||
const appStatus = AppSupervisor.getInstance().getAppStatus(handleAppName, 'initializing');
|
||||
|
||||
if (appStatus === 'not_found') {
|
||||
this.sendMessageToConnection(client, {
|
||||
type: 'maintaining',
|
||||
payload: getPayloadByErrorCode('APP_NOT_FOUND', { appName: handleAppName }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (appStatus === 'initializing') {
|
||||
this.sendMessageToConnection(client, {
|
||||
type: 'maintaining',
|
||||
payload: getPayloadByErrorCode('APP_INITIALIZING', { appName: handleAppName }),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const app = await AppSupervisor.getInstance().getApp(handleAppName);
|
||||
|
||||
this.sendMessageToConnection(client, {
|
||||
type: 'maintaining',
|
||||
payload: getPayloadByErrorCode(appStatus, { app }),
|
||||
});
|
||||
}
|
||||
|
||||
removeConnection(id: string) {
|
||||
console.log(`client disconnected ${id}`);
|
||||
this.webSocketClients.delete(id);
|
||||
}
|
||||
|
||||
sendMessageToConnection(client: WebSocketClient, sendMessage: object) {
|
||||
client.ws.send(JSON.stringify(sendMessage));
|
||||
}
|
||||
|
||||
sendToConnectionsByTag(tagName: string, tagValue: string, sendMessage: object) {
|
||||
this.loopThroughConnections((client: WebSocketClient) => {
|
||||
if (client.tags.includes(`${tagName}#${tagValue}`)) {
|
||||
this.sendMessageToConnection(client, sendMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loopThroughConnections(callback: (client: WebSocketClient) => void) {
|
||||
this.webSocketClients.forEach((client) => {
|
||||
callback(client);
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.wss.close();
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import cors from '@koa/cors';
|
||||
import Database from '@nocobase/database';
|
||||
import Resourcer from '@nocobase/resourcer';
|
||||
import { Command } from 'commander';
|
||||
import i18next from 'i18next';
|
||||
import bodyParser from 'koa-bodyparser';
|
||||
import Application, { ApplicationOptions } from './application';
|
||||
@ -78,3 +79,31 @@ export function registerMiddlewares(app: Application, options: ApplicationOption
|
||||
app.use(db2resource, { tag: 'db2resource', after: 'dataWrapping' });
|
||||
app.use(app.resourcer.restApiMiddleware(), { tag: 'restApi', after: 'db2resource' });
|
||||
}
|
||||
|
||||
export const createAppProxy = (app: Application) => {
|
||||
return new Proxy(app, {
|
||||
get(target, prop, ...args) {
|
||||
if (typeof prop === 'string' && ['on', 'once', 'addListener'].includes(prop)) {
|
||||
return (eventName: string, listener: any) => {
|
||||
listener['_reinitializable'] = true;
|
||||
return target[prop](eventName, listener);
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, prop, ...args);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getCommandFullName = (command: Command) => {
|
||||
const names = [];
|
||||
names.push(command.name());
|
||||
let parent = command?.parent;
|
||||
while (parent) {
|
||||
if (!parent?.parent) {
|
||||
break;
|
||||
}
|
||||
names.unshift(parent.name());
|
||||
parent = parent.parent;
|
||||
}
|
||||
return names.join('.');
|
||||
};
|
||||
|
56
packages/core/server/src/helpers/application-version.ts
Normal file
56
packages/core/server/src/helpers/application-version.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Collection } from '@nocobase/database';
|
||||
import semver from 'semver';
|
||||
import Application from '../application';
|
||||
|
||||
export class ApplicationVersion {
|
||||
protected app: Application;
|
||||
protected collection: Collection;
|
||||
|
||||
constructor(app: Application) {
|
||||
this.app = app;
|
||||
if (!app.db.hasCollection('applicationVersion')) {
|
||||
app.db.collection({
|
||||
name: 'applicationVersion',
|
||||
namespace: 'core.applicationVersion',
|
||||
duplicator: 'required',
|
||||
timestamps: false,
|
||||
fields: [{ name: 'value', type: 'string' }],
|
||||
});
|
||||
}
|
||||
this.collection = this.app.db.getCollection('applicationVersion');
|
||||
}
|
||||
|
||||
async get() {
|
||||
if (await this.app.db.collectionExistsInDb('applicationVersion')) {
|
||||
const model = await this.collection.model.findOne();
|
||||
if (!model) {
|
||||
return null;
|
||||
}
|
||||
return model.get('value') as any;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async update() {
|
||||
await this.collection.sync();
|
||||
await this.collection.model.destroy({
|
||||
truncate: true,
|
||||
});
|
||||
|
||||
await this.collection.model.create({
|
||||
value: this.app.getVersion(),
|
||||
});
|
||||
}
|
||||
|
||||
async satisfies(range: string) {
|
||||
if (await this.app.db.collectionExistsInDb('applicationVersion')) {
|
||||
const model: any = await this.collection.model.findOne();
|
||||
const version = model?.value as any;
|
||||
if (!version) {
|
||||
return true;
|
||||
}
|
||||
return semver.satisfies(version, range, { includePrerelease: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
export { AppManager } from './app-manager';
|
||||
export * from './application';
|
||||
export { Application as default } from './application';
|
||||
export * as middlewares from './middlewares';
|
||||
@ -6,3 +5,5 @@ export * from './migration';
|
||||
export * from './plugin';
|
||||
export * from './plugin-manager';
|
||||
export * from './read-config';
|
||||
export * from './gateway';
|
||||
export * from './app-supervisor';
|
||||
|
@ -14,11 +14,17 @@ export class Locale {
|
||||
this.app = app;
|
||||
this.cache = createCache();
|
||||
|
||||
this.app.on('afterLoad', () => this.load());
|
||||
this.app.on('afterLoad', async () => {
|
||||
this.app.log.debug('load locale resource');
|
||||
this.app.setMaintainingMessage('load locale resource');
|
||||
await this.load();
|
||||
this.app.log.debug('locale resource loaded');
|
||||
this.app.setMaintainingMessage('locale resource loaded');
|
||||
});
|
||||
}
|
||||
|
||||
load() {
|
||||
this.getCacheResources(this.defaultLang);
|
||||
async load() {
|
||||
await this.get(this.defaultLang);
|
||||
}
|
||||
|
||||
setLocaleFn(name: string, fn: (lang: string) => Promise<any>) {
|
||||
@ -30,6 +36,7 @@ export class Locale {
|
||||
resources: await this.getCacheResources(lang),
|
||||
};
|
||||
for (const [name, fn] of this.localeFn) {
|
||||
// this.app.log.debug(`load [${name}] locale resource `);
|
||||
const result = await this.wrapCache(`locale:${name}:${lang}`, async () => await fn(lang));
|
||||
if (result) {
|
||||
defaults[name] = result;
|
||||
@ -57,10 +64,12 @@ export class Locale {
|
||||
|
||||
getResources(lang: string) {
|
||||
const resources = {};
|
||||
const plugins = this.app.pm.getPlugins();
|
||||
for (const name of plugins.keys()) {
|
||||
const names = this.app.pm.getAliases();
|
||||
for (const name of names) {
|
||||
try {
|
||||
const packageName = PluginManager.getPackageName(name);
|
||||
// this.app.log.debug(`load [${packageName}] locale resource `);
|
||||
// this.app.setMaintainingMessage(`load [${packageName}] locale resource `);
|
||||
const res = getResource(packageName, lang);
|
||||
if (res) {
|
||||
resources[name] = { ...res };
|
||||
@ -69,10 +78,6 @@ export class Locale {
|
||||
// empty
|
||||
}
|
||||
}
|
||||
const res = getResource('@nocobase/client', lang, false);
|
||||
if (res) {
|
||||
resources['client'] = { ...(resources['client'] || {}), ...res };
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,4 @@
|
||||
import fs from 'fs';
|
||||
import send from 'koa-send';
|
||||
import path from 'path';
|
||||
|
||||
const PREFIX = '/api/plugins/client/';
|
||||
const cwd = process.cwd();
|
||||
const NODE_MODULES = path.join(cwd, 'node_modules');
|
||||
|
||||
/**
|
||||
* get plugin client static file url
|
||||
@ -16,72 +10,3 @@ const NODE_MODULES = path.join(cwd, 'node_modules');
|
||||
export const getPackageClientStaticUrl = (packageName: string, filePath: string) => {
|
||||
return `${PREFIX}${packageName}/${filePath}`;
|
||||
};
|
||||
|
||||
const isMatchClientStaticUrl = (url: string) => {
|
||||
return url.startsWith(PREFIX);
|
||||
};
|
||||
|
||||
/**
|
||||
* get package name from url
|
||||
*
|
||||
* @example
|
||||
* /api/plugins/client/@nocobase/plugin-acl/index.js => @nocobase/plugin-acl
|
||||
* /api/plugins/client/my-plugin/README.md => my-plugin
|
||||
*/
|
||||
const getPackageName = (url: string) => {
|
||||
const urlArr = url.split('/');
|
||||
return urlArr[4].startsWith('@') ? `${urlArr[4]}/${urlArr[5]}` : urlArr[4];
|
||||
};
|
||||
|
||||
/**
|
||||
* get plugin client static file real path
|
||||
*
|
||||
* @example
|
||||
* /api/plugins/client/@nocobase/plugin-acl/index.js => /node_modules/@nocobase/plugin-acl/dist/client/index.js
|
||||
* /api/plugins/client/my-plugin/README.md => /node_modules/my-plugin/dist/client/README.md
|
||||
*/
|
||||
const getRealPath = (packageName: string, url: string) => {
|
||||
const ext = path.extname(url);
|
||||
const filePath = url.replace(`${PREFIX}${packageName}/`, '');
|
||||
if (ext.toLowerCase() === '.md') {
|
||||
return path.join(NODE_MODULES, packageName, filePath);
|
||||
} else {
|
||||
return path.join(NODE_MODULES, packageName, 'dist', 'client', filePath);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* send plugin client static file to browser.
|
||||
*
|
||||
* such as:
|
||||
* /api/plugins/client/@nocobase/plugin-xxx/index.js
|
||||
* /api/plugins/client/xxx/README.md
|
||||
*/
|
||||
export const clientStaticMiddleware = async (ctx, next) => {
|
||||
if (isMatchClientStaticUrl(ctx.path)) {
|
||||
// TODO: check packageName in plugins
|
||||
const packageName = getPackageName(ctx.path);
|
||||
|
||||
const realPath = getRealPath(packageName, ctx.path);
|
||||
|
||||
// get file stats
|
||||
const stats = await fs.promises.stat(realPath);
|
||||
const ifModifiedSince = ctx.get('If-Modified-Since');
|
||||
const lastModified = stats.mtime.toUTCString();
|
||||
|
||||
// check cache headers
|
||||
if (ifModifiedSince === lastModified) {
|
||||
ctx.status = 304;
|
||||
return;
|
||||
}
|
||||
|
||||
// `send` only accept relative path
|
||||
const relativePath = path.relative(cwd, realPath);
|
||||
await send(ctx, relativePath, {
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('Last-Modified', lastModified);
|
||||
},
|
||||
});
|
||||
}
|
||||
await next();
|
||||
};
|
||||
|
@ -1,3 +1,5 @@
|
||||
import Application from '../../application';
|
||||
|
||||
export default {
|
||||
name: 'pm',
|
||||
actions: {
|
||||
@ -14,10 +16,11 @@ export default {
|
||||
async enable(ctx, next) {
|
||||
const pm = ctx.app.pm;
|
||||
const { filterByTk } = ctx.action.params;
|
||||
const app = ctx.app as Application;
|
||||
if (!filterByTk) {
|
||||
ctx.throw(400, 'plugin name invalid');
|
||||
}
|
||||
await pm.enable(filterByTk);
|
||||
app.runAsCLI(['pm', 'enable', filterByTk], { from: 'user' });
|
||||
ctx.body = filterByTk;
|
||||
await next();
|
||||
},
|
||||
@ -27,7 +30,8 @@ export default {
|
||||
if (!filterByTk) {
|
||||
ctx.throw(400, 'plugin name invalid');
|
||||
}
|
||||
await pm.disable(filterByTk);
|
||||
const app = ctx.app as Application;
|
||||
app.runAsCLI(['pm', 'disable', filterByTk], { from: 'user' });
|
||||
ctx.body = filterByTk;
|
||||
await next();
|
||||
},
|
||||
@ -41,7 +45,8 @@ export default {
|
||||
if (!filterByTk) {
|
||||
ctx.throw(400, 'plugin name invalid');
|
||||
}
|
||||
await pm.remove(filterByTk);
|
||||
const app = ctx.app as Application;
|
||||
app.runAsCLI(['pm', 'remove', filterByTk], { from: 'user' });
|
||||
ctx.body = filterByTk;
|
||||
await next();
|
||||
},
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Repository } from '@nocobase/database';
|
||||
import lodash from 'lodash';
|
||||
import { PluginManager } from './plugin-manager';
|
||||
|
||||
export class PluginManagerRepository extends Repository {
|
||||
@ -17,13 +18,13 @@ export class PluginManagerRepository extends Repository {
|
||||
}
|
||||
|
||||
async enable(name: string | string[]) {
|
||||
const pluginNames = typeof name === 'string' ? [name] : name;
|
||||
const plugins = pluginNames.map((name) => this.pm.plugins.get(name));
|
||||
const pluginNames = lodash.castArray(name);
|
||||
const plugins = pluginNames.map((name) => this.pm.get(name));
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const requiredPlugins = plugin.requiredPlugins();
|
||||
for (const requiredPluginName of requiredPlugins) {
|
||||
const requiredPlugin = this.pm.plugins.get(requiredPluginName);
|
||||
const requiredPlugin = this.pm.get(requiredPluginName);
|
||||
if (!requiredPlugin.enabled) {
|
||||
throw new Error(`${plugin.name} plugin need ${requiredPluginName} plugin enabled`);
|
||||
}
|
||||
@ -47,11 +48,17 @@ export class PluginManagerRepository extends Repository {
|
||||
}
|
||||
|
||||
async disable(name: string | string[]) {
|
||||
const pluginNames = typeof name === 'string' ? [name] : name;
|
||||
await this.update({
|
||||
filter: {
|
||||
name = lodash.cloneDeep(name);
|
||||
|
||||
const pluginNames = lodash.castArray(name);
|
||||
console.log(`disable ${name}, ${pluginNames}`);
|
||||
const filter = {
|
||||
name,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(filter, null, 2));
|
||||
await this.update({
|
||||
filter,
|
||||
values: {
|
||||
enabled: false,
|
||||
installed: false,
|
||||
@ -60,19 +67,38 @@ export class PluginManagerRepository extends Repository {
|
||||
return pluginNames;
|
||||
}
|
||||
|
||||
async load() {
|
||||
async getItems() {
|
||||
try {
|
||||
// sort plugins by id
|
||||
const items = await this.find({
|
||||
return await this.find({
|
||||
sort: 'id',
|
||||
});
|
||||
} catch (error) {
|
||||
await this.collection.sync({
|
||||
alter: {
|
||||
drop: false,
|
||||
},
|
||||
force: false,
|
||||
});
|
||||
return await this.find({
|
||||
sort: 'id',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
const exists = await this.collection.existsInDb();
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await this.getItems();
|
||||
|
||||
for (const item of items) {
|
||||
await this.pm.addStatic(item.get('name'), {
|
||||
...item.get('options'),
|
||||
name: item.get('name'),
|
||||
version: item.get('version'),
|
||||
enabled: item.get('enabled'),
|
||||
async: true,
|
||||
const { options, ...others } = item.toJSON();
|
||||
await this.pm.add(item.get('name'), {
|
||||
...others,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,12 @@
|
||||
import { CleanOptions, Collection, SyncOptions } from '@nocobase/database';
|
||||
import { requireModule } from '@nocobase/utils';
|
||||
import execa from 'execa';
|
||||
import fs from 'fs';
|
||||
import _ from 'lodash';
|
||||
import net from 'net';
|
||||
import { resolve } from 'path';
|
||||
import xpipe from 'xpipe';
|
||||
import Application from '../application';
|
||||
import { createAppProxy } from '../helper';
|
||||
import { Plugin } from '../plugin';
|
||||
import { clientStaticMiddleware } from './clientStaticMiddleware';
|
||||
import collectionOptions from './options/collection';
|
||||
import resourceOptions from './options/resource';
|
||||
import { PluginManagerRepository } from './plugin-manager-repository';
|
||||
@ -23,31 +22,28 @@ export interface InstallOptions {
|
||||
sync?: SyncOptions;
|
||||
}
|
||||
|
||||
export class AddPresetError extends Error {}
|
||||
|
||||
export class PluginManager {
|
||||
app: Application;
|
||||
collection: Collection;
|
||||
repository: PluginManagerRepository;
|
||||
plugins = new Map<string, Plugin>();
|
||||
_repository: PluginManagerRepository;
|
||||
pluginInstances = new Map<typeof Plugin, Plugin>();
|
||||
pluginAliases = new Map<string, Plugin>();
|
||||
server: net.Server;
|
||||
pmSock: string;
|
||||
_tmpPluginArgs = [];
|
||||
|
||||
constructor(options: PluginManagerOptions) {
|
||||
constructor(public options: PluginManagerOptions) {
|
||||
this.app = options.app;
|
||||
const f = resolve(process.cwd(), 'storage', 'pm.sock');
|
||||
this.pmSock = xpipe.eq(this.app.options.pmSock || f);
|
||||
this.app.db.registerRepositories({
|
||||
PluginManagerRepository,
|
||||
});
|
||||
|
||||
this.collection = this.app.db.collection(collectionOptions);
|
||||
|
||||
this.repository = this.collection.repository as PluginManagerRepository;
|
||||
this.repository.setPluginManager(this);
|
||||
this._repository = this.collection.repository as PluginManagerRepository;
|
||||
this._repository.setPluginManager(this);
|
||||
this.app.resourcer.define(resourceOptions);
|
||||
|
||||
this.app.use(clientStaticMiddleware);
|
||||
|
||||
this.app.resourcer.use(async (ctx, next) => {
|
||||
await next();
|
||||
const { resourceName, actionName } = ctx.action;
|
||||
@ -72,297 +68,10 @@ export class PluginManager {
|
||||
name: 'pm',
|
||||
actions: ['pm:*', 'applicationPlugins:list'],
|
||||
});
|
||||
|
||||
this.app.on('beforeLoad', async (app, options) => {
|
||||
if (options?.method && ['install', 'upgrade'].includes(options.method)) {
|
||||
await this.collection.sync();
|
||||
}
|
||||
|
||||
const exists = await this.app.db.collectionExistsInDb('applicationPlugins');
|
||||
|
||||
if (!exists) {
|
||||
this.app.log.warn(`applicationPlugins collection not exists in ${this.app.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.method !== 'install' || options.reload) {
|
||||
await this.repository.load();
|
||||
}
|
||||
});
|
||||
|
||||
this.app.on('beforeUpgrade', async () => {
|
||||
await this.collection.sync();
|
||||
});
|
||||
|
||||
this.addStaticMultiple(options.plugins);
|
||||
}
|
||||
|
||||
addStaticMultiple(plugins: any) {
|
||||
for (const plugin of plugins || []) {
|
||||
if (typeof plugin == 'string') {
|
||||
this.addStatic(plugin);
|
||||
} else {
|
||||
this.addStatic(...plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getPlugins() {
|
||||
return this.plugins;
|
||||
}
|
||||
|
||||
get(name: string) {
|
||||
return this.plugins.get(name);
|
||||
}
|
||||
|
||||
has(name: string) {
|
||||
return this.plugins.has(name);
|
||||
}
|
||||
|
||||
clientWrite(data: any) {
|
||||
const { method, plugins } = data;
|
||||
if (method === 'create') {
|
||||
try {
|
||||
console.log(method, plugins);
|
||||
this[method](plugins);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const client = new net.Socket();
|
||||
client.connect(this.pmSock, () => {
|
||||
client.write(JSON.stringify(data));
|
||||
client.end();
|
||||
});
|
||||
client.on('error', async () => {
|
||||
try {
|
||||
console.log(method, plugins);
|
||||
await this[method](plugins);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async listen(): Promise<net.Server> {
|
||||
this.server = net.createServer((socket) => {
|
||||
socket.on('data', async (data) => {
|
||||
const { method, plugins } = JSON.parse(data.toString());
|
||||
try {
|
||||
console.log(method, plugins);
|
||||
await this[method](plugins);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
});
|
||||
socket.pipe(socket);
|
||||
});
|
||||
|
||||
if (fs.existsSync(this.pmSock)) {
|
||||
await fs.promises.unlink(this.pmSock);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.server.listen(this.pmSock, () => {
|
||||
resolve(this.server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async create(name: string | string[]) {
|
||||
console.log('creating...');
|
||||
const pluginNames = Array.isArray(name) ? name : [name];
|
||||
const { run } = require('@nocobase/cli/src/util');
|
||||
const createPlugin = async (name) => {
|
||||
const { PluginGenerator } = require('@nocobase/cli/src/plugin-generator');
|
||||
const generator = new PluginGenerator({
|
||||
cwd: resolve(process.cwd(), name),
|
||||
args: {},
|
||||
context: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
await generator.run();
|
||||
};
|
||||
await Promise.all(pluginNames.map((pluginName) => createPlugin(pluginName)));
|
||||
await run('yarn', ['install']);
|
||||
}
|
||||
|
||||
clone() {
|
||||
const pm = new PluginManager({
|
||||
app: this.app,
|
||||
});
|
||||
for (const arg of this._tmpPluginArgs) {
|
||||
pm.addStatic(...arg);
|
||||
}
|
||||
return pm;
|
||||
}
|
||||
|
||||
addStatic(plugin?: any, options?: any) {
|
||||
if (!options?.async) {
|
||||
this._tmpPluginArgs.push([plugin, options]);
|
||||
}
|
||||
|
||||
let name: string;
|
||||
if (typeof plugin === 'string') {
|
||||
name = plugin;
|
||||
plugin = PluginManager.resolvePlugin(plugin);
|
||||
} else {
|
||||
name = plugin.name;
|
||||
if (!name) {
|
||||
throw new Error(`plugin name invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new plugin(this.app, {
|
||||
name,
|
||||
enabled: true,
|
||||
...options,
|
||||
});
|
||||
|
||||
const pluginName = instance.getName();
|
||||
|
||||
if (this.plugins.has(pluginName)) {
|
||||
throw new Error(`plugin name [${pluginName}] already exists`);
|
||||
}
|
||||
|
||||
this.plugins.set(pluginName, instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
async add(plugin: any, options: any = {}, transaction?: any) {
|
||||
if (Array.isArray(plugin)) {
|
||||
const t = transaction || (await this.app.db.sequelize.transaction());
|
||||
try {
|
||||
const items = [];
|
||||
|
||||
for (const p of plugin) {
|
||||
items.push(await this.add(p, options, t));
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
return items;
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const packageName = await PluginManager.findPackage(plugin);
|
||||
|
||||
const instance = this.addStatic(plugin, {
|
||||
...options,
|
||||
async: true,
|
||||
});
|
||||
|
||||
const model = await this.repository.findOne({
|
||||
transaction,
|
||||
filter: { name: plugin },
|
||||
});
|
||||
|
||||
const packageJson = PluginManager.getPackageJson(packageName);
|
||||
|
||||
if (!model) {
|
||||
const { enabled, builtIn, installed, ...others } = options;
|
||||
await this.repository.create({
|
||||
transaction,
|
||||
values: {
|
||||
name: plugin,
|
||||
version: packageJson.version,
|
||||
enabled: !!enabled,
|
||||
builtIn: !!builtIn,
|
||||
installed: !!installed,
|
||||
options: {
|
||||
...others,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
async load(options: any = {}) {
|
||||
for (const [name, plugin] of this.plugins) {
|
||||
if (!plugin.enabled) {
|
||||
continue;
|
||||
}
|
||||
await plugin.beforeLoad();
|
||||
}
|
||||
|
||||
for (const [name, plugin] of this.plugins) {
|
||||
if (!plugin.enabled) {
|
||||
continue;
|
||||
}
|
||||
await this.app.emitAsync('beforeLoadPlugin', plugin, options);
|
||||
await plugin.load();
|
||||
await this.app.emitAsync('afterLoadPlugin', plugin, options);
|
||||
}
|
||||
}
|
||||
|
||||
async install(options: InstallOptions = {}) {
|
||||
for (const [name, plugin] of this.plugins) {
|
||||
if (!plugin.enabled) {
|
||||
continue;
|
||||
}
|
||||
await this.app.emitAsync('beforeInstallPlugin', plugin, options);
|
||||
await plugin.install(options);
|
||||
await this.app.emitAsync('afterInstallPlugin', plugin, options);
|
||||
}
|
||||
}
|
||||
|
||||
async enable(name: string | string[]) {
|
||||
try {
|
||||
const pluginNames = await this.repository.enable(name);
|
||||
await this.app.reload();
|
||||
|
||||
await this.app.db.sync();
|
||||
for (const pluginName of pluginNames) {
|
||||
const plugin = this.app.getPlugin(pluginName);
|
||||
if (!plugin) {
|
||||
throw new Error(`${name} plugin does not exist`);
|
||||
}
|
||||
if (!plugin.options.installed) {
|
||||
await plugin.install();
|
||||
plugin.options.installed = true;
|
||||
}
|
||||
await plugin.afterEnable();
|
||||
}
|
||||
|
||||
await this.app.emitAsync('afterEnablePlugin', name);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async disable(name: string | string[]) {
|
||||
try {
|
||||
const pluginNames = await this.repository.disable(name);
|
||||
await this.app.reload();
|
||||
for (const pluginName of pluginNames) {
|
||||
const plugin = this.app.getPlugin(pluginName);
|
||||
if (!plugin) {
|
||||
throw new Error(`${name} plugin does not exist`);
|
||||
}
|
||||
await plugin.afterDisable();
|
||||
}
|
||||
|
||||
await this.app.emitAsync('afterDisablePlugin', name);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(name: string | string[]) {
|
||||
const pluginNames = typeof name === 'string' ? [name] : name;
|
||||
for (const pluginName of pluginNames) {
|
||||
const plugin = this.app.getPlugin(pluginName);
|
||||
if (!plugin) {
|
||||
throw new Error(`${name} plugin does not exist`);
|
||||
}
|
||||
await plugin.remove();
|
||||
}
|
||||
await this.repository.remove(name);
|
||||
this.app.reload();
|
||||
get repository() {
|
||||
return this.app.db.getRepository('applicationPlugins') as PluginManagerRepository;
|
||||
}
|
||||
|
||||
static getPackageJson(packageName: string) {
|
||||
@ -412,9 +121,354 @@ export class PluginManager {
|
||||
throw new Error(`No available packages found, ${name} plugin does not exist`);
|
||||
}
|
||||
|
||||
static resolvePlugin(pluginName: string) {
|
||||
static resolvePlugin(pluginName: string | typeof Plugin) {
|
||||
if (typeof pluginName === 'string') {
|
||||
const packageName = this.getPackageName(pluginName);
|
||||
return requireModule(packageName);
|
||||
} else {
|
||||
return pluginName;
|
||||
}
|
||||
}
|
||||
|
||||
addPreset(plugin: string | typeof Plugin, options: any = {}) {
|
||||
if (this.app.loaded) {
|
||||
throw new AddPresetError('must be added before executing app.load()');
|
||||
}
|
||||
if (!this.options.plugins) {
|
||||
this.options.plugins = [];
|
||||
}
|
||||
this.options.plugins.push([plugin, options]);
|
||||
}
|
||||
|
||||
getPlugins() {
|
||||
return this.pluginInstances;
|
||||
}
|
||||
|
||||
getAliases() {
|
||||
return this.pluginAliases.keys();
|
||||
}
|
||||
|
||||
get(name: string | typeof Plugin) {
|
||||
if (typeof name === 'string') {
|
||||
return this.pluginAliases.get(name);
|
||||
}
|
||||
return this.pluginInstances.get(name);
|
||||
}
|
||||
|
||||
has(name: string | typeof Plugin) {
|
||||
if (typeof name === 'string') {
|
||||
return this.pluginAliases.has(name);
|
||||
}
|
||||
return this.pluginInstances.has(name);
|
||||
}
|
||||
|
||||
del(name: string | typeof Plugin) {
|
||||
const instance = this.get(name);
|
||||
if (instance) {
|
||||
this.pluginAliases.delete(instance.name);
|
||||
this.pluginInstances.delete(instance.constructor as typeof Plugin);
|
||||
}
|
||||
}
|
||||
|
||||
async create(name: string | string[]) {
|
||||
console.log('creating...');
|
||||
const pluginNames = Array.isArray(name) ? name : [name];
|
||||
const { run } = require('@nocobase/cli/src/util');
|
||||
const createPlugin = async (name) => {
|
||||
const { PluginGenerator } = require('@nocobase/cli/src/plugin-generator');
|
||||
const generator = new PluginGenerator({
|
||||
cwd: resolve(process.cwd(), name),
|
||||
args: {},
|
||||
context: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
await generator.run();
|
||||
};
|
||||
await Promise.all(pluginNames.map((pluginName) => createPlugin(pluginName)));
|
||||
await run('yarn', ['install']);
|
||||
}
|
||||
|
||||
async add(plugin?: any, options: any = {}) {
|
||||
if (this.has(plugin)) {
|
||||
const name = typeof plugin === 'string' ? plugin : plugin.name;
|
||||
this.app.log.warn(`plugin [${name}] added`);
|
||||
return;
|
||||
}
|
||||
if (!options.name && typeof plugin === 'string') {
|
||||
options.name = plugin;
|
||||
}
|
||||
this.app.log.debug(`adding plugin [${options.name}]...`);
|
||||
let P: any;
|
||||
try {
|
||||
P = PluginManager.resolvePlugin(plugin);
|
||||
} catch (error) {
|
||||
this.app.log.warn('plugin not found', error);
|
||||
return;
|
||||
}
|
||||
const instance: Plugin = new P(createAppProxy(this.app), options);
|
||||
this.pluginInstances.set(P, instance);
|
||||
if (options.name) {
|
||||
this.pluginAliases.set(options.name, instance);
|
||||
}
|
||||
await instance.afterAdd();
|
||||
}
|
||||
|
||||
async initPlugins() {
|
||||
await this.initPresetPlugins();
|
||||
await this.repository.init();
|
||||
}
|
||||
|
||||
async load(options: any = {}) {
|
||||
this.app.setMaintainingMessage('loading plugins...');
|
||||
const total = this.pluginInstances.size;
|
||||
|
||||
let current = 0;
|
||||
|
||||
for (const [P, plugin] of this.getPlugins()) {
|
||||
if (plugin.state.loaded) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = P.name;
|
||||
current += 1;
|
||||
|
||||
this.app.setMaintainingMessage(`before load plugin [${name}], ${current}/${total}`);
|
||||
if (!plugin.enabled) {
|
||||
continue;
|
||||
}
|
||||
this.app.logger.debug(`before load plugin [${name}]...`);
|
||||
await plugin.beforeLoad();
|
||||
}
|
||||
|
||||
current = 0;
|
||||
|
||||
for (const [P, plugin] of this.getPlugins()) {
|
||||
if (plugin.state.loaded) {
|
||||
continue;
|
||||
}
|
||||
const name = P.name;
|
||||
current += 1;
|
||||
this.app.setMaintainingMessage(`load plugin [${name}], ${current}/${total}`);
|
||||
|
||||
if (!plugin.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.app.emitAsync('beforeLoadPlugin', plugin, options);
|
||||
this.app.logger.debug(`loading plugin [${name}]...`);
|
||||
await plugin.load();
|
||||
plugin.state.loaded = true;
|
||||
await this.app.emitAsync('afterLoadPlugin', plugin, options);
|
||||
this.app.logger.debug(`after load plugin [${name}]...`);
|
||||
}
|
||||
|
||||
this.app.setMaintainingMessage('loaded plugins');
|
||||
}
|
||||
|
||||
async install(options: InstallOptions = {}) {
|
||||
this.app.setMaintainingMessage('install plugins...');
|
||||
const total = this.pluginInstances.size;
|
||||
let current = 0;
|
||||
|
||||
this.app.log.debug('call db.sync()');
|
||||
await this.app.db.sync();
|
||||
const toBeUpdated = [];
|
||||
|
||||
for (const [P, plugin] of this.getPlugins()) {
|
||||
if (plugin.state.installing || plugin.state.installed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = P.name;
|
||||
current += 1;
|
||||
|
||||
if (!plugin.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
plugin.state.installing = true;
|
||||
this.app.setMaintainingMessage(`before install plugin [${name}], ${current}/${total}`);
|
||||
await this.app.emitAsync('beforeInstallPlugin', plugin, options);
|
||||
this.app.logger.debug(`install plugin [${name}]...`);
|
||||
await plugin.install(options);
|
||||
toBeUpdated.push(name);
|
||||
plugin.state.installing = false;
|
||||
plugin.state.installed = true;
|
||||
plugin.installed = true;
|
||||
this.app.setMaintainingMessage(`after install plugin [${name}], ${current}/${total}`);
|
||||
await this.app.emitAsync('afterInstallPlugin', plugin, options);
|
||||
}
|
||||
await this.repository.update({
|
||||
filter: {
|
||||
name: toBeUpdated,
|
||||
},
|
||||
values: {
|
||||
installed: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async enable(name: string | string[]) {
|
||||
const pluginNames = _.castArray(name);
|
||||
this.app.log.debug(`enabling plugin ${pluginNames.join(',')}`);
|
||||
this.app.setMaintainingMessage(`enabling plugin ${pluginNames.join(',')}`);
|
||||
const toBeUpdated = [];
|
||||
for (const pluginName of pluginNames) {
|
||||
const plugin = this.get(pluginName);
|
||||
if (!plugin) {
|
||||
throw new Error(`${pluginName} plugin does not exist`);
|
||||
}
|
||||
if (plugin.enabled) {
|
||||
continue;
|
||||
}
|
||||
await this.app.emitAsync('beforeEnablePlugin', pluginName);
|
||||
await plugin.beforeEnable();
|
||||
plugin.enabled = true;
|
||||
toBeUpdated.push(pluginName);
|
||||
}
|
||||
if (toBeUpdated.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.repository.update({
|
||||
filter: {
|
||||
name: toBeUpdated,
|
||||
},
|
||||
values: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await this.app.reload();
|
||||
this.app.log.debug(`syncing database in enable plugin ${pluginNames.join(',')}...`);
|
||||
this.app.setMaintainingMessage(`syncing database in enable plugin ${pluginNames.join(',')}...`);
|
||||
await this.app.db.sync();
|
||||
for (const pluginName of pluginNames) {
|
||||
const plugin = this.get(pluginName);
|
||||
if (!plugin.installed) {
|
||||
this.app.log.debug(`installing plugin ${pluginName}...`);
|
||||
this.app.setMaintainingMessage(`installing plugin ${pluginName}...`);
|
||||
await plugin.install();
|
||||
plugin.installed = true;
|
||||
}
|
||||
}
|
||||
await this.repository.update({
|
||||
filter: {
|
||||
name: toBeUpdated,
|
||||
},
|
||||
values: {
|
||||
installed: true,
|
||||
},
|
||||
});
|
||||
for (const pluginName of pluginNames) {
|
||||
const plugin = this.get(pluginName);
|
||||
this.app.log.debug(`emit afterEnablePlugin event...`);
|
||||
await plugin.afterEnable();
|
||||
await this.app.emitAsync('afterEnablePlugin', pluginName);
|
||||
this.app.log.debug(`afterEnablePlugin event emitted`);
|
||||
}
|
||||
await this.app.tryReloadOrRestart();
|
||||
} catch (error) {
|
||||
await this.repository.update({
|
||||
filter: {
|
||||
name: toBeUpdated,
|
||||
},
|
||||
values: {
|
||||
enabled: false,
|
||||
installed: false,
|
||||
},
|
||||
});
|
||||
await this.app.tryReloadOrRestart();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async disable(name: string | string[]) {
|
||||
const pluginNames = _.castArray(name);
|
||||
this.app.log.debug(`disabling plugin ${pluginNames.join(',')}`);
|
||||
this.app.setMaintainingMessage(`disabling plugin ${pluginNames.join(',')}`);
|
||||
const toBeUpdated = [];
|
||||
for (const pluginName of pluginNames) {
|
||||
const plugin = this.get(pluginName);
|
||||
if (!plugin) {
|
||||
throw new Error(`${pluginName} plugin does not exist`);
|
||||
}
|
||||
if (!plugin.enabled) {
|
||||
continue;
|
||||
}
|
||||
await this.app.emitAsync('beforeDisablePlugin', pluginName);
|
||||
await plugin.beforeDisable();
|
||||
plugin.enabled = false;
|
||||
toBeUpdated.push(pluginName);
|
||||
}
|
||||
if (toBeUpdated.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.repository.update({
|
||||
filter: {
|
||||
name: toBeUpdated,
|
||||
},
|
||||
values: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await this.app.tryReloadOrRestart();
|
||||
for (const pluginName of pluginNames) {
|
||||
const plugin = this.get(pluginName);
|
||||
this.app.log.debug(`emit afterDisablePlugin event...`);
|
||||
await plugin.afterDisable();
|
||||
await this.app.emitAsync('afterDisablePlugin', pluginName);
|
||||
this.app.log.debug(`afterDisablePlugin event emitted`);
|
||||
}
|
||||
} catch (error) {
|
||||
await this.repository.update({
|
||||
filter: {
|
||||
name: toBeUpdated,
|
||||
},
|
||||
values: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
await this.app.tryReloadOrRestart();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(name: string | string[]) {
|
||||
const pluginNames = _.castArray(name);
|
||||
for (const pluginName of pluginNames) {
|
||||
const plugin = this.get(pluginName);
|
||||
if (!plugin) {
|
||||
throw new Error(`${pluginName} plugin does not exist`);
|
||||
}
|
||||
if (plugin.enabled) {
|
||||
throw new Error(`${pluginName} plugin is enabled`);
|
||||
}
|
||||
await plugin.beforeRemove();
|
||||
}
|
||||
await this.repository.destroy({
|
||||
filter: {
|
||||
name: pluginNames,
|
||||
},
|
||||
});
|
||||
const plugins: Plugin[] = [];
|
||||
for (const pluginName of pluginNames) {
|
||||
const plugin = this.get(pluginName);
|
||||
plugins.push(plugin);
|
||||
this.del(pluginName);
|
||||
}
|
||||
await this.app.reload();
|
||||
for (const plugin of plugins) {
|
||||
await plugin.afterRemove();
|
||||
}
|
||||
}
|
||||
|
||||
protected async initPresetPlugins() {
|
||||
for (const plugin of this.options.plugins) {
|
||||
const [p, opts = {}] = Array.isArray(plugin) ? plugin : [plugin];
|
||||
await this.add(p, { enabled: true, isPreset: true, ...opts });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Model } from '@nocobase/database';
|
||||
import { Application } from './application';
|
||||
import { InstallOptions } from './plugin-manager';
|
||||
|
||||
@ -22,24 +23,29 @@ export interface PluginOptions {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type PluginType = typeof Plugin;
|
||||
|
||||
export abstract class Plugin<O = any> implements PluginInterface {
|
||||
options: any;
|
||||
app: Application;
|
||||
model: Model;
|
||||
state: any = {};
|
||||
|
||||
constructor(app: Application, options?: any) {
|
||||
this.setOptions(options);
|
||||
|
||||
this.app = app;
|
||||
this.setOptions(options);
|
||||
this.afterAdd();
|
||||
}
|
||||
|
||||
get log() {
|
||||
return this.app.log;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.options.name as string;
|
||||
}
|
||||
|
||||
get pm() {
|
||||
return this.app.pm;
|
||||
}
|
||||
|
||||
get db() {
|
||||
return this.app.db;
|
||||
}
|
||||
@ -52,6 +58,14 @@ export abstract class Plugin<O = any> implements PluginInterface {
|
||||
this.options.enabled = value;
|
||||
}
|
||||
|
||||
get installed() {
|
||||
return this.options.installed;
|
||||
}
|
||||
|
||||
set installed(value) {
|
||||
this.options.installed = value;
|
||||
}
|
||||
|
||||
setOptions(options: any) {
|
||||
this.options = options || {};
|
||||
}
|
||||
@ -72,9 +86,13 @@ export abstract class Plugin<O = any> implements PluginInterface {
|
||||
|
||||
async afterEnable() {}
|
||||
|
||||
async beforeDisable() {}
|
||||
|
||||
async afterDisable() {}
|
||||
|
||||
async remove() {}
|
||||
async beforeRemove() {}
|
||||
|
||||
async afterRemove() {}
|
||||
|
||||
async importCollections(collectionsPath: string) {
|
||||
await this.db.import({
|
||||
|
@ -12,7 +12,8 @@
|
||||
"pg": "^8.7.3",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"sqlite3": "^5.0.8",
|
||||
"supertest": "^6.1.6"
|
||||
"supertest": "^6.1.6",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import ws from 'ws';
|
||||
|
||||
export { mockDatabase } from '@nocobase/database';
|
||||
export * from './mockServer';
|
||||
export { default as supertest } from 'supertest';
|
||||
|
||||
export const pgOnly: () => jest.Describe = () => (process.env.DB_DIALECT == 'postgres' ? describe : describe.skip);
|
||||
|
||||
@ -7,3 +10,54 @@ export function randomStr() {
|
||||
// create random string
|
||||
return Math.random().toString(36).substring(2);
|
||||
}
|
||||
|
||||
export const waitSecond = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
};
|
||||
|
||||
export const startServerWithRandomPort = async (startServer) => {
|
||||
return await new Promise((resolve) => {
|
||||
startServer({
|
||||
port: 0,
|
||||
host: 'localhost',
|
||||
callback(server) {
|
||||
// @ts-ignore
|
||||
const port = server.address().port;
|
||||
resolve(port);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const createWsClient = async ({ serverPort, options = {} }) => {
|
||||
console.log(`connect to ws://localhost:${serverPort}/ws`, options);
|
||||
|
||||
const wsc = new ws(`ws://localhost:${serverPort}/ws`, options);
|
||||
const messages = [];
|
||||
|
||||
wsc.on('message', (data) => {
|
||||
const message = data.toString();
|
||||
messages.push(message);
|
||||
});
|
||||
|
||||
// await connection established
|
||||
await new Promise((resolve) => {
|
||||
wsc.on('open', resolve);
|
||||
});
|
||||
|
||||
return {
|
||||
wsc,
|
||||
messages,
|
||||
async stop() {
|
||||
const promise = new Promise((resolve) => {
|
||||
wsc.on('close', resolve);
|
||||
});
|
||||
|
||||
wsc.close();
|
||||
await promise;
|
||||
},
|
||||
lastMessage() {
|
||||
return JSON.parse(messages[messages.length - 1]);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Database, mockDatabase } from '@nocobase/database';
|
||||
import Application, { ApplicationOptions, PluginManager } from '@nocobase/server';
|
||||
import Application, { AppSupervisor, ApplicationOptions, Gateway, PluginManager } from '@nocobase/server';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import qs from 'qs';
|
||||
import supertest, { SuperAgentTest } from 'supertest';
|
||||
@ -81,12 +81,19 @@ export class MockServer extends Application {
|
||||
await this.db.clean({ drop: true });
|
||||
}
|
||||
|
||||
async destroy(options: any = {}): Promise<void> {
|
||||
await super.destroy(options);
|
||||
|
||||
Gateway.getInstance().destroy();
|
||||
await AppSupervisor.getInstance().destroy();
|
||||
}
|
||||
|
||||
agent(): SuperAgentTest & {
|
||||
login: (user: any) => SuperAgentTest;
|
||||
loginUsingId: (userId: number) => SuperAgentTest;
|
||||
resource: (name: string, resourceOf?: any) => Resource;
|
||||
} {
|
||||
const agent = supertest.agent(this.appManager.callback());
|
||||
const agent = supertest.agent(this.callback());
|
||||
const prefix = this.resourcer.options.prefix;
|
||||
const proxy = new Proxy(agent, {
|
||||
get(target, method: string, receiver) {
|
||||
@ -116,7 +123,8 @@ export class MockServer extends Application {
|
||||
{
|
||||
get(target, method: string, receiver) {
|
||||
return (params: ActionParams = {}) => {
|
||||
let { filterByTk, values = {}, file, ...restParams } = params;
|
||||
let { filterByTk } = params;
|
||||
const { values = {}, file, ...restParams } = params;
|
||||
if (params.associatedIndex) {
|
||||
resourceOf = params.associatedIndex;
|
||||
}
|
||||
@ -172,6 +180,9 @@ export function mockServer(options: ApplicationOptions = {}) {
|
||||
global.TextDecoder = require('util').TextDecoder;
|
||||
}
|
||||
|
||||
Gateway.getInstance().reset();
|
||||
AppSupervisor.getInstance().reset();
|
||||
|
||||
// @ts-ignore
|
||||
if (!PluginManager.findPackagePatched) {
|
||||
PluginManager.getPackageJson = () => {
|
||||
|
@ -13,8 +13,6 @@ export async function prepareApp(): Promise<MockServer> {
|
||||
});
|
||||
|
||||
await app.loadAndInstall({ clean: true });
|
||||
|
||||
await app.db.sync();
|
||||
|
||||
await app.start();
|
||||
return app;
|
||||
}
|
||||
|
@ -31,7 +31,7 @@ describe('actions', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('update profile with roles', async () => {
|
||||
|
@ -342,23 +342,17 @@ export class PluginACL extends Plugin {
|
||||
}
|
||||
});
|
||||
|
||||
// sync database role data to acl
|
||||
this.app.on('afterLoad', async (app, options) => {
|
||||
if (options?.method === 'install' || options?.method === 'upgrade') {
|
||||
return;
|
||||
}
|
||||
const writeRolesToACL = async (app, options) => {
|
||||
const exists = await this.app.db.collectionExistsInDb('roles');
|
||||
if (exists) {
|
||||
this.log.info('write roles to ACL');
|
||||
await this.writeRolesToACL();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.app.on('afterInstall', async (app, options) => {
|
||||
const exists = await this.app.db.collectionExistsInDb('roles');
|
||||
if (exists) {
|
||||
await this.writeRolesToACL();
|
||||
}
|
||||
});
|
||||
// sync database role data to acl
|
||||
this.app.on('afterLoad', writeRolesToACL);
|
||||
this.app.on('afterInstall', writeRolesToACL);
|
||||
|
||||
this.app.on('afterInstallPlugin', async (plugin) => {
|
||||
if (plugin.getName() !== 'users') {
|
||||
|
@ -26,7 +26,7 @@ describe('actions', () => {
|
||||
await repo.destroy({
|
||||
truncate: true,
|
||||
});
|
||||
await db.close();
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
let user;
|
||||
@ -54,7 +54,7 @@ describe('actions', () => {
|
||||
},
|
||||
});
|
||||
|
||||
role = await (db.getRepository('users.roles', user.id) as unknown as Repository).findOne({
|
||||
role = await ((db.getRepository('users.roles', user.id) as unknown) as Repository).findOne({
|
||||
where: {
|
||||
default: true,
|
||||
},
|
||||
|
@ -8,9 +8,8 @@ describe('hook', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
api = mockServer();
|
||||
await api.db.clean({ drop: true });
|
||||
api.plugin(logPlugin, { name: 'audit-logs' });
|
||||
await api.load();
|
||||
await api.loadAndInstall({ clean: true });
|
||||
db = api.db;
|
||||
db.collection({
|
||||
name: 'posts',
|
||||
|
@ -27,7 +27,7 @@ describe('actions', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.close();
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should list authenticator types', async () => {
|
||||
|
@ -19,7 +19,7 @@ describe('token-blacklist', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.close();
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@ -66,7 +66,6 @@ describe('token-blacklist', () => {
|
||||
token: 'should not be deleted',
|
||||
expiration: new Date('2100-01-01'),
|
||||
});
|
||||
await tokenBlacklist.deleteByExpiration();
|
||||
expect(await tokenBlacklist.has('should be deleted')).not.toBeTruthy();
|
||||
expect(await tokenBlacklist.has('should not be deleted')).toBeTruthy();
|
||||
});
|
||||
|
@ -9,36 +9,12 @@ export class TokenBlacklistService implements ITokenBlacklistService {
|
||||
|
||||
constructor(protected plugin: AuthPlugin) {
|
||||
this.repo = plugin.db.getRepository('tokenBlacklist');
|
||||
this.cronJob = this.createCronJob();
|
||||
}
|
||||
|
||||
get app() {
|
||||
return this.plugin.app;
|
||||
}
|
||||
|
||||
createCronJob() {
|
||||
const cronJob = new CronJob(
|
||||
// every day at 03:00
|
||||
'0 3 * * *', //
|
||||
async () => {
|
||||
this.app.logger.info(`${this.plugin.name}: Start delete expired blacklist token`);
|
||||
await this.deleteByExpiration();
|
||||
this.app.logger.info(`${this.plugin.name}: End delete expired blacklist token`);
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
this.app.once('beforeStart', () => {
|
||||
cronJob.start();
|
||||
});
|
||||
|
||||
this.app.once('beforeStop', () => {
|
||||
cronJob.stop();
|
||||
});
|
||||
|
||||
return cronJob;
|
||||
}
|
||||
|
||||
async has(token: string) {
|
||||
return !!(await this.repo.findOne({
|
||||
filter: {
|
||||
@ -46,7 +22,9 @@ export class TokenBlacklistService implements ITokenBlacklistService {
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async add(values) {
|
||||
await this.deleteExpiredTokens();
|
||||
return this.repo.model.findOrCreate({
|
||||
defaults: values,
|
||||
where: {
|
||||
@ -54,7 +32,8 @@ export class TokenBlacklistService implements ITokenBlacklistService {
|
||||
},
|
||||
});
|
||||
}
|
||||
async deleteByExpiration() {
|
||||
|
||||
async deleteExpiredTokens() {
|
||||
return this.repo.destroy({
|
||||
filter: {
|
||||
expiration: {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user