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
|
DEFAULT_STORAGE_TYPE=local
|
||||||
|
|
||||||
# LOCAL STORAGE
|
# LOCAL STORAGE
|
||||||
LOCAL_STORAGE_BASE_URL=
|
LOCAL_STORAGE_BASE_URL=/storage/uploads
|
||||||
LOCAL_STORAGE_DEST=storage/uploads
|
LOCAL_STORAGE_DEST=storage/uploads
|
||||||
|
|
||||||
# ALI OSS STORAGE
|
# 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_DIALECT: sqlite
|
||||||
DB_STORAGE: /tmp/db.sqlite
|
DB_STORAGE: /tmp/db.sqlite
|
||||||
DB_UNDERSCORED: ${{ matrix.underscored }}
|
DB_UNDERSCORED: ${{ matrix.underscored }}
|
||||||
timeout-minutes: 35
|
timeout-minutes: 40
|
||||||
|
|
||||||
postgres-test:
|
postgres-test:
|
||||||
strategy:
|
strategy:
|
||||||
@ -92,7 +92,7 @@ jobs:
|
|||||||
DB_UNDERSCORED: ${{ matrix.underscored }}
|
DB_UNDERSCORED: ${{ matrix.underscored }}
|
||||||
DB_SCHEMA: ${{ matrix.schema }}
|
DB_SCHEMA: ${{ matrix.schema }}
|
||||||
COLLECTION_MANAGER_SCHEMA: ${{ matrix.collection_schema }}
|
COLLECTION_MANAGER_SCHEMA: ${{ matrix.collection_schema }}
|
||||||
timeout-minutes: 35
|
timeout-minutes: 40
|
||||||
|
|
||||||
mysql-test:
|
mysql-test:
|
||||||
strategy:
|
strategy:
|
||||||
@ -128,4 +128,4 @@ jobs:
|
|||||||
DB_PASSWORD: password
|
DB_PASSWORD: password
|
||||||
DB_DATABASE: nocobase
|
DB_DATABASE: nocobase
|
||||||
DB_UNDERSCORED: ${{ matrix.underscored }}
|
DB_UNDERSCORED: ${{ matrix.underscored }}
|
||||||
timeout-minutes: 35
|
timeout-minutes: 40
|
||||||
|
@ -40,7 +40,6 @@ RUN cd /app \
|
|||||||
|
|
||||||
FROM node:16.20-bullseye-slim
|
FROM node:16.20-bullseye-slim
|
||||||
RUN apt-get update && apt-get install -y nginx
|
RUN apt-get update && apt-get install -y nginx
|
||||||
|
|
||||||
RUN rm -rf /etc/nginx/sites-enabled/default
|
RUN rm -rf /etc/nginx/sites-enabled/default
|
||||||
COPY ./docker/nocobase/nocobase.conf /etc/nginx/sites-enabled/nocobase.conf
|
COPY ./docker/nocobase/nocobase.conf /etc/nginx/sites-enabled/nocobase.conf
|
||||||
COPY --from=builder /app/nocobase.tar.gz /app/nocobase.tar.gz
|
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
|
## 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.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.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)
|
- [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
|
## 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.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)
|
- [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.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.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)
|
- [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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
@ -5,6 +19,8 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
client_max_body_size 20M;
|
client_max_body_size 20M;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/nocobase.log apm;
|
||||||
|
|
||||||
location /storage/uploads/ {
|
location /storage/uploads/ {
|
||||||
alias /app/nocobase/storage/uploads/;
|
alias /app/nocobase/storage/uploads/;
|
||||||
autoindex off;
|
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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
root /app/nocobase/node_modules/@nocobase/app/dist/client;
|
root /app/nocobase/node_modules/@nocobase/app/dist/client;
|
||||||
index index.html;
|
index index.html;
|
||||||
client_max_body_size 20M;
|
client_max_body_size 20M;
|
||||||
|
access_log /var/log/nginx/nocobase.log apm;
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
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;
|
proxy_read_timeout 600;
|
||||||
send_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/index',
|
||||||
// '/welcome/release/v08-changelog',
|
// '/welcome/release/v08-changelog',
|
||||||
|
'/welcome/release/v13-changelog',
|
||||||
'/welcome/release/v12-changelog',
|
'/welcome/release/v12-changelog',
|
||||||
'/welcome/release/v11-changelog',
|
'/welcome/release/v11-changelog',
|
||||||
'/welcome/release/v10-changelog',
|
'/welcome/release/v10-changelog',
|
||||||
|
@ -1,8 +1,14 @@
|
|||||||
# Upgrading for `create-nocobase-app`
|
# 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
|
```bash
|
||||||
# Switch to the corresponding directory
|
# Switch to the corresponding directory
|
||||||
@ -13,63 +19,4 @@ yarn nocobase upgrade
|
|||||||
yarn dev
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Major upgrade
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
@ -12,16 +12,12 @@ cd my-nocobase-app
|
|||||||
git pull
|
git pull
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. 删除旧依赖文件(非必须)
|
## 3. Delete cache and dependencies (optional)
|
||||||
|
|
||||||
v0.10 进行了依赖的重大升级,如果 v0.9 升级 v0.10,需要删掉以下目录之后再升级
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Remove .umi cache
|
# delete nocobase cache
|
||||||
yarn rimraf -rf "./**/{.umi,.umi-production}"
|
yarn nocobase clean
|
||||||
# Delete compiled files
|
# delete dependencies
|
||||||
yarn rimraf -rf "./packages/*/*/{lib,esm,es,dist,node_modules}"
|
|
||||||
# Remove dependencies
|
|
||||||
yarn rimraf -rf node_modules
|
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` 安装的升级
|
# `create-nocobase-app` 安装的升级
|
||||||
|
|
||||||
<Alert>
|
<Alert>
|
||||||
通过 create-nocobase-app 安装的应用不再有 packages/app 目录了,在 packages/app 里自定义的代码,需要移至自定义插件中。
|
v0.12 之后的版本,通过 create-nocobase-app 安装的应用不再有 packages/app 目录了,在 packages/app 里自定义的代码,需要移至自定义插件中。
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
## 小版本升级
|
## 版本升级
|
||||||
|
|
||||||
执行 `nocobase upgrade` 升级命令即可
|
v0.12 之后的版本,应用的升级直接执行 `yarn nocobase upgrade` 升级命令即可
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 切换到对应的目录
|
# 切换到对应的目录
|
||||||
@ -17,63 +17,4 @@ yarn nocobase upgrade
|
|||||||
yarn dev
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## 大版本升级
|
如果升级存在问题,也可以[重新创建新应用](/welcome/getting-started/installation/create-nocobase-app),并参考旧版本的 .env 修改环境变量。数据库信息需要配置正确,使用 SQLite 数据库时,需要将数据库文件复制到 `./storage/db/` 目录。最后再执行 `yarn nocobase upgrade` 进行升级。
|
||||||
|
|
||||||
如果小版本升级失效,也可以采用此升级办法。
|
|
||||||
|
|
||||||
### 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。
|
|
||||||
|
@ -12,16 +12,14 @@ cd my-nocobase-app
|
|||||||
git pull
|
git pull
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. 删除旧依赖文件(非必须)
|
## 3. 删除缓存和旧依赖(非必须)
|
||||||
|
|
||||||
v0.10 进行了依赖的重大升级,如果 v0.9 升级 v0.10,需要删掉以下目录之后再升级
|
如果正常的升级流程失败,可以尝试清空缓存和依赖之后重新下载
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 删除 .umi 相关缓存
|
# 删除 nocobase 缓存
|
||||||
yarn rimraf -rf "./**/{.umi,.umi-production}"
|
yarn nocobase clean
|
||||||
# 删除编译文件
|
# 删除依赖
|
||||||
yarn rimraf -rf "./packages/*/*/{lib,esm,es,dist,node_modules}"
|
|
||||||
# 删除全部依赖
|
|
||||||
yarn rimraf -rf node_modules
|
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', () => {
|
describe('app test', () => {
|
||||||
let app: MockServer;
|
let app: MockServer;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
app = mockServer();
|
app = mockServer();
|
||||||
|
await app.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test1', async () => {
|
test('test1', async () => {
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
"nocobase": "nocobase",
|
"nocobase": "nocobase",
|
||||||
"pm": "nocobase pm",
|
"pm": "nocobase pm",
|
||||||
"dev": "nocobase dev",
|
"dev": "nocobase dev",
|
||||||
|
"dev-server": "nocobase dev --server",
|
||||||
"start": "nocobase start",
|
"start": "nocobase start",
|
||||||
"build": "nocobase build",
|
"build": "nocobase build",
|
||||||
"test": "nocobase test",
|
"test": "nocobase test",
|
||||||
|
@ -7,6 +7,7 @@ export const app = new Application({
|
|||||||
baseURL: process.env.API_BASE_URL,
|
baseURL: process.env.API_BASE_URL,
|
||||||
},
|
},
|
||||||
plugins: [NocoBaseClientPresetPlugin],
|
plugins: [NocoBaseClientPresetPlugin],
|
||||||
|
ws: true,
|
||||||
devDynamicImport,
|
devDynamicImport,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
import { Application } from '@nocobase/server';
|
import { Gateway } from '@nocobase/server';
|
||||||
import config from './config';
|
import config from './config';
|
||||||
|
|
||||||
const app = new Application(config);
|
Gateway.getInstance().run({
|
||||||
|
mainAppOptions: config,
|
||||||
if (require.main === module) {
|
});
|
||||||
app.runAsCLI();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
|
@ -68,7 +68,7 @@ describe('auth-manager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await db.close();
|
await app.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('blacklist', () => {
|
describe('blacklist', () => {
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
"execa": "^5.1.1",
|
"execa": "^5.1.1",
|
||||||
"fast-glob": "^3.2.12",
|
"fast-glob": "^3.2.12",
|
||||||
"fs-extra": "^11.1.1",
|
"fs-extra": "^11.1.1",
|
||||||
|
"newrelic": "^10.6.2",
|
||||||
"pm2": "^5.2.0",
|
"pm2": "^5.2.0",
|
||||||
"portfinder": "^1.0.28",
|
"portfinder": "^1.0.28",
|
||||||
"serve": "^13.0.2",
|
"serve": "^13.0.2",
|
||||||
|
@ -54,8 +54,6 @@ module.exports = (cli) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await runAppCommand('install', ['--silent']);
|
|
||||||
|
|
||||||
if (server || !client) {
|
if (server || !client) {
|
||||||
console.log('starting server', serverPort);
|
console.log('starting server', serverPort);
|
||||||
|
|
||||||
@ -99,6 +97,7 @@ module.exports = (cli) => {
|
|||||||
env: {
|
env: {
|
||||||
PORT: clientPort,
|
PORT: clientPort,
|
||||||
APP_ROOT: `${APP_PACKAGE_ROOT}/client`,
|
APP_ROOT: `${APP_PACKAGE_ROOT}/client`,
|
||||||
|
WEBSOCKET_URL: process.env.WEBSOCKET_URL || `ws://localhost:${serverPort}/ws`,
|
||||||
PROXY_TARGET_URL:
|
PROXY_TARGET_URL:
|
||||||
process.env.PROXY_TARGET_URL || (serverPort ? `http://127.0.0.1:${serverPort}` : undefined),
|
process.env.PROXY_TARGET_URL || (serverPort ? `http://127.0.0.1:${serverPort}` : undefined),
|
||||||
},
|
},
|
||||||
|
@ -12,11 +12,10 @@ module.exports = (cli) => {
|
|||||||
.option('-h, --help')
|
.option('-h, --help')
|
||||||
.option('--ts-node-dev')
|
.option('--ts-node-dev')
|
||||||
.action((options) => {
|
.action((options) => {
|
||||||
const { tsNodeDev } = options;
|
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
promptForTs();
|
promptForTs();
|
||||||
run(tsNodeDev ? 'ts-node-dev' : 'ts-node', [
|
run('tsx', [
|
||||||
'-P',
|
'--tsconfig',
|
||||||
'./tsconfig.server.json',
|
'./tsconfig.server.json',
|
||||||
'-r',
|
'-r',
|
||||||
'tsconfig-paths/register',
|
'tsconfig-paths/register',
|
||||||
|
@ -41,16 +41,6 @@ module.exports = (cli) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await postCheck(opts);
|
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) {
|
if (opts.daemon) {
|
||||||
run('pm2', ['start', `${APP_PACKAGE_ROOT}/lib/index.js`, '--', ...process.argv.slice(2)]);
|
run('pm2', ['start', `${APP_PACKAGE_ROOT}/lib/index.js`, '--', ...process.argv.slice(2)]);
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { Field } from '@formily/core';
|
import { Field } from '@formily/core';
|
||||||
import { Schema, useField, useFieldSchema } from '@formily/react';
|
import { Schema, useField, useFieldSchema } from '@formily/react';
|
||||||
import { Spin } from 'antd';
|
|
||||||
import React, { createContext, useContext, useEffect } from 'react';
|
import React, { createContext, useContext, useEffect } from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { useAPIClient, useRequest } from '../api-client';
|
import { useAPIClient, useRequest } from '../api-client';
|
||||||
|
import { useAppSpin } from '../application/hooks/useAppSpin';
|
||||||
import { useBlockRequestContext } from '../block-provider/BlockProvider';
|
import { useBlockRequestContext } from '../block-provider/BlockProvider';
|
||||||
import { useCollection } from '../collection-manager';
|
import { useCollection } from '../collection-manager';
|
||||||
import { useResourceActionContext } from '../collection-manager/ResourceActionProvider';
|
import { useResourceActionContext } from '../collection-manager/ResourceActionProvider';
|
||||||
@ -33,6 +33,7 @@ const getRouteUrl = (props) => {
|
|||||||
export const ACLRolesCheckProvider = (props) => {
|
export const ACLRolesCheckProvider = (props) => {
|
||||||
const route = getRouteUrl(props.children.props);
|
const route = getRouteUrl(props.children.props);
|
||||||
const { setDesignable } = useDesignable();
|
const { setDesignable } = useDesignable();
|
||||||
|
const { render } = useAppSpin();
|
||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
const result = useRequest<{
|
const result = useRequest<{
|
||||||
data: {
|
data: {
|
||||||
@ -60,7 +61,7 @@ export const ACLRolesCheckProvider = (props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (result.loading) {
|
if (result.loading) {
|
||||||
return <Spin />;
|
return render();
|
||||||
}
|
}
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return <Navigate replace to={'/signin'} />;
|
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 { Result } from 'ahooks/es/useRequest/src/types';
|
||||||
import { notification } from 'antd';
|
import { notification } from 'antd';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Application } from '../application';
|
||||||
|
|
||||||
const handleErrorMessage = (error, notification) => {
|
const handleErrorMessage = (error, notification) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@ -9,7 +10,7 @@ const handleErrorMessage = (error, notification) => {
|
|||||||
reader.onload = function () {
|
reader.onload = function () {
|
||||||
notification.error({
|
notification.error({
|
||||||
message: JSON.parse(reader.result as string).errors?.map?.((error: any) => {
|
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 {
|
export class APIClient extends APIClientSDK {
|
||||||
services: Record<string, Result<any, any>> = {};
|
services: Record<string, Result<any, any>> = {};
|
||||||
silence = false;
|
silence = false;
|
||||||
|
app: Application;
|
||||||
/** 该值会在 AntdAppProvider 中被重新赋值 */
|
/** 该值会在 AntdAppProvider 中被重新赋值 */
|
||||||
notification: any = notification;
|
notification: any = notification;
|
||||||
|
|
||||||
@ -56,6 +58,16 @@ export class APIClient extends APIClientSDK {
|
|||||||
if (errorCache.size > 10) {
|
if (errorCache.size > 10) {
|
||||||
errorCache.clear();
|
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' }];
|
let errs = error?.response?.data?.errors || [{ message: 'Server error' }];
|
||||||
errs = errs.filter((error) => {
|
errs = errs.filter((error) => {
|
||||||
const lastTime = errorCache.get(error.message);
|
const lastTime = errorCache.get(error.message);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Spin } from 'antd';
|
|
||||||
import React, { createContext, useContext } from 'react';
|
import React, { createContext, useContext } from 'react';
|
||||||
import { useRequest } from '../api-client';
|
import { useRequest } from '../api-client';
|
||||||
|
import { useAppSpin } from '../application/hooks/useAppSpin';
|
||||||
|
|
||||||
export const CurrentAppInfoContext = createContext(null);
|
export const CurrentAppInfoContext = createContext(null);
|
||||||
|
|
||||||
@ -16,11 +16,12 @@ export const useCurrentAppInfo = () => {
|
|||||||
}>(CurrentAppInfoContext);
|
}>(CurrentAppInfoContext);
|
||||||
};
|
};
|
||||||
export const CurrentAppInfoProvider = (props) => {
|
export const CurrentAppInfoProvider = (props) => {
|
||||||
|
const { render } = useAppSpin();
|
||||||
const result = useRequest({
|
const result = useRequest({
|
||||||
url: 'app:getInfo',
|
url: 'app:getInfo',
|
||||||
});
|
});
|
||||||
if (result.loading) {
|
if (result.loading) {
|
||||||
return <Spin />;
|
return render();
|
||||||
}
|
}
|
||||||
return <CurrentAppInfoContext.Provider value={result.data}>{props.children}</CurrentAppInfoContext.Provider>;
|
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 { APIClientOptions } from '@nocobase/sdk';
|
||||||
import { i18n as i18next } from 'i18next';
|
import { i18n as i18next } from 'i18next';
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
@ -12,11 +13,12 @@ import { i18n } from '../i18n';
|
|||||||
import type { Plugin } from './Plugin';
|
import type { Plugin } from './Plugin';
|
||||||
import { PluginManager, PluginType } from './PluginManager';
|
import { PluginManager, PluginType } from './PluginManager';
|
||||||
import { ComponentTypeAndString, RouterManager, RouterOptions } from './RouterManager';
|
import { ComponentTypeAndString, RouterManager, RouterOptions } from './RouterManager';
|
||||||
|
import { WebSocketClient, WebSocketClientOptions } from './WebSocketClient';
|
||||||
import { AppComponent, BlankComponent, defaultAppComponents } from './components';
|
import { AppComponent, BlankComponent, defaultAppComponents } from './components';
|
||||||
import { compose, normalizeContainer } from './utils';
|
import { compose, normalizeContainer } from './utils';
|
||||||
import { defineGlobalDeps } from './utils/globalDeps';
|
import { defineGlobalDeps } from './utils/globalDeps';
|
||||||
import { getRequireJs } from './utils/requirejs';
|
|
||||||
import type { RequireJS } from './utils/requirejs';
|
import type { RequireJS } from './utils/requirejs';
|
||||||
|
import { getRequireJs } from './utils/requirejs';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -28,6 +30,7 @@ export type DevDynamicImport = (packageName: string) => Promise<{ default: typeo
|
|||||||
export type ComponentAndProps<T = any> = [ComponentType, T];
|
export type ComponentAndProps<T = any> = [ComponentType, T];
|
||||||
export interface ApplicationOptions {
|
export interface ApplicationOptions {
|
||||||
apiClient?: APIClientOptions;
|
apiClient?: APIClientOptions;
|
||||||
|
ws?: WebSocketClientOptions | boolean;
|
||||||
i18n?: i18next;
|
i18n?: i18next;
|
||||||
providers?: (ComponentType | ComponentAndProps)[];
|
providers?: (ComponentType | ComponentAndProps)[];
|
||||||
plugins?: PluginType[];
|
plugins?: PluginType[];
|
||||||
@ -42,18 +45,30 @@ export class Application {
|
|||||||
public router: RouterManager;
|
public router: RouterManager;
|
||||||
public scopes: Record<string, any> = {};
|
public scopes: Record<string, any> = {};
|
||||||
public i18n: i18next;
|
public i18n: i18next;
|
||||||
|
public ws: WebSocketClient;
|
||||||
public apiClient: APIClient;
|
public apiClient: APIClient;
|
||||||
public components: Record<string, ComponentType> = { ...defaultAppComponents };
|
public components: Record<string, ComponentType> = { ...defaultAppComponents };
|
||||||
public pm: PluginManager;
|
public pm: PluginManager;
|
||||||
public devDynamicImport: DevDynamicImport;
|
public devDynamicImport: DevDynamicImport;
|
||||||
public requirejs: RequireJS;
|
public requirejs: RequireJS;
|
||||||
|
loading = true;
|
||||||
|
maintained = false;
|
||||||
|
maintaining = false;
|
||||||
|
error = null;
|
||||||
|
|
||||||
constructor(protected options: ApplicationOptions = {}) {
|
constructor(protected options: ApplicationOptions = {}) {
|
||||||
this.initRequireJs();
|
this.initRequireJs();
|
||||||
|
define(this, {
|
||||||
|
maintained: observable.ref,
|
||||||
|
loading: observable.ref,
|
||||||
|
maintaining: observable.ref,
|
||||||
|
error: observable.ref,
|
||||||
|
});
|
||||||
this.devDynamicImport = options.devDynamicImport;
|
this.devDynamicImport = options.devDynamicImport;
|
||||||
this.scopes = merge(this.scopes, options.scopes);
|
this.scopes = merge(this.scopes, options.scopes);
|
||||||
this.components = merge(this.components, options.components);
|
this.components = merge(this.components, options.components);
|
||||||
this.apiClient = new APIClient(options.apiClient);
|
this.apiClient = new APIClient(options.apiClient);
|
||||||
|
this.apiClient.app = this;
|
||||||
this.i18n = options.i18n || i18n;
|
this.i18n = options.i18n || i18n;
|
||||||
this.router = new RouterManager({
|
this.router = new RouterManager({
|
||||||
...options.router,
|
...options.router,
|
||||||
@ -63,6 +78,7 @@ export class Application {
|
|||||||
this.addDefaultProviders();
|
this.addDefaultProviders();
|
||||||
this.addReactRouterComponents();
|
this.addReactRouterComponents();
|
||||||
this.addProviders(options.providers || []);
|
this.addProviders(options.providers || []);
|
||||||
|
this.ws = new WebSocketClient(options.ws);
|
||||||
}
|
}
|
||||||
|
|
||||||
private initRequireJs() {
|
private initRequireJs() {
|
||||||
@ -109,7 +125,38 @@ export class Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
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 {
|
getComponent<T = any>(Component: ComponentTypeAndString<T>, isShowError = true): ComponentType<T> | undefined {
|
||||||
|
@ -41,7 +41,7 @@ export class PluginManager {
|
|||||||
private async initRemotePlugins() {
|
private async initRemotePlugins() {
|
||||||
try {
|
try {
|
||||||
const res = await this.app.apiClient.request({ url: 'app:getPlugins' });
|
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({
|
const plugins = await getPlugins({
|
||||||
requirejs: this.app.requirejs,
|
requirejs: this.app.requirejs,
|
||||||
pluginData: pluginList,
|
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 { ErrorBoundary, FallbackProps } from 'react-error-boundary';
|
||||||
import type { Application } from '../Application';
|
import type { Application } from '../Application';
|
||||||
import { ApplicationContext } from '../context';
|
import { ApplicationContext } from '../context';
|
||||||
import { useAppPluginLoad } from '../hooks';
|
|
||||||
|
|
||||||
export interface AppComponentProps {
|
export interface AppComponentProps {
|
||||||
app: Application;
|
app: Application;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppComponent: FC<AppComponentProps> = (props) => {
|
export const AppComponent: FC<AppComponentProps> = observer((props) => {
|
||||||
const { app } = props;
|
const { app } = props;
|
||||||
const { loading, error } = useAppPluginLoad(app);
|
|
||||||
const handleErrors = useCallback((error: Error, info: { componentStack: string }) => {
|
const handleErrors = useCallback((error: Error, info: { componentStack: string }) => {
|
||||||
console.error(error, info);
|
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 (
|
return (
|
||||||
<ErrorBoundary FallbackComponent={app.getComponent<FallbackProps>('ErrorFallback')} onError={handleErrors}>
|
<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>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { createContext } from 'react';
|
import { createContext } from 'react';
|
||||||
import type { Application } from './Application';
|
import { Application } from './Application';
|
||||||
|
|
||||||
export const ApplicationContext = createContext<Application>(null);
|
export const ApplicationContext = createContext<Application>(null);
|
||||||
|
@ -5,6 +5,9 @@ export function useAppPluginLoad(app: Application) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
app.ws.on('message', (event) => {
|
||||||
|
console.log(event.data);
|
||||||
|
});
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
await app.load();
|
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 React, { createContext, useContext, useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useAPIClient, useRequest } from '../api-client';
|
import { useAPIClient, useRequest } from '../api-client';
|
||||||
|
import { useAppSpin } from '../application/hooks/useAppSpin';
|
||||||
|
|
||||||
export interface CollectionHistoryContextValue {
|
export interface CollectionHistoryContextValue {
|
||||||
historyCollections: any[];
|
historyCollections: any[];
|
||||||
@ -41,6 +41,7 @@ export const CollectionHistoryProvider: React.FC = (props) => {
|
|||||||
|
|
||||||
const isAdminPage = location.pathname.startsWith('/admin');
|
const isAdminPage = location.pathname.startsWith('/admin');
|
||||||
const token = api.auth.getToken() || '';
|
const token = api.auth.getToken() || '';
|
||||||
|
const { render } = useAppSpin();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAdminPage && token) {
|
if (isAdminPage && token) {
|
||||||
@ -56,7 +57,7 @@ export const CollectionHistoryProvider: React.FC = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (service.loading) {
|
if (service.loading) {
|
||||||
return <Spin />;
|
return render();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Spin } from 'antd';
|
|
||||||
import { keyBy } from 'lodash';
|
import { keyBy } from 'lodash';
|
||||||
import React, { useContext, useState } from 'react';
|
import React, { useContext, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAPIClient, useRequest } from '../api-client';
|
import { useAPIClient, useRequest } from '../api-client';
|
||||||
|
import { useAppSpin } from '../application/hooks/useAppSpin';
|
||||||
import { templateOptions } from '../collection-manager/Configuration/templates';
|
import { templateOptions } from '../collection-manager/Configuration/templates';
|
||||||
import { useCollectionHistory } from './CollectionHistoryProvider';
|
import { useCollectionHistory } from './CollectionHistoryProvider';
|
||||||
import { CollectionManagerSchemaComponentProvider } from './CollectionManagerSchemaComponentProvider';
|
import { CollectionManagerSchemaComponentProvider } from './CollectionManagerSchemaComponentProvider';
|
||||||
@ -36,6 +36,7 @@ export const RemoteCollectionManagerProvider = (props: any) => {
|
|||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
const [contentLoading, setContentLoading] = useState(false);
|
const [contentLoading, setContentLoading] = useState(false);
|
||||||
const { refreshCH } = useCollectionHistory();
|
const { refreshCH } = useCollectionHistory();
|
||||||
|
const { render } = useAppSpin();
|
||||||
const options = {
|
const options = {
|
||||||
resource: 'collections',
|
resource: 'collections',
|
||||||
action: 'list',
|
action: 'list',
|
||||||
@ -63,7 +64,7 @@ export const RemoteCollectionManagerProvider = (props: any) => {
|
|||||||
}>(coptions);
|
}>(coptions);
|
||||||
|
|
||||||
if (service.loading) {
|
if (service.loading) {
|
||||||
return <Spin />;
|
return render();
|
||||||
}
|
}
|
||||||
const refreshCM = async (opts) => {
|
const refreshCM = async (opts) => {
|
||||||
if (opts?.reload) {
|
if (opts?.reload) {
|
||||||
|
@ -769,7 +769,7 @@ export default {
|
|||||||
'Reload application': '重载应用',
|
'Reload application': '重载应用',
|
||||||
'The application is reloading, please do not close the page.': '应用正在重新加载,请勿关闭页面。',
|
'The application is reloading, please do not close the page.': '应用正在重新加载,请勿关闭页面。',
|
||||||
'Application reloading': '应用重新加载中',
|
'Application reloading': '应用重新加载中',
|
||||||
'Reboot application': '重启应用',
|
'Restart application': '重启应用',
|
||||||
"Allows to clear cache, reboot application": "允许清除缓存,重启应用",
|
"Allows to clear cache, reboot application": "允许清除缓存,重启应用",
|
||||||
'The will interrupt service, it may take a few seconds to restart. Are you sure to continue?': '重启将会中断当前服务,这个过程可能需要一点时间,确定要继续吗?',
|
'The will interrupt service, it may take a few seconds to restart. Are you sure to continue?': '重启将会中断当前服务,这个过程可能需要一点时间,确定要继续吗?',
|
||||||
'Reboot': '重启',
|
'Reboot': '重启',
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
import { LoadingOutlined } from '@ant-design/icons';
|
||||||
import { css } from '@emotion/css';
|
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 React, { FC } from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { ACLPlugin } from '../acl';
|
import { ACLPlugin } from '../acl';
|
||||||
@ -21,9 +23,13 @@ import { SystemSettingsPlugin } from '../system-settings';
|
|||||||
import { CurrentUserProvider, CurrentUserSettingsMenuProvider } from '../user';
|
import { CurrentUserProvider, CurrentUserSettingsMenuProvider } from '../user';
|
||||||
import { LocalePlugin } from './plugins/LocalePlugin';
|
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>
|
<div>
|
||||||
<Result
|
<Result
|
||||||
className={css`
|
className={css`
|
||||||
@ -34,7 +40,7 @@ const AppError: FC<{ app: Application; error: Error }> = ({ app, error }) => (
|
|||||||
`}
|
`}
|
||||||
status="error"
|
status="error"
|
||||||
title={app.i18n.t('Failed to load plugin')}
|
title={app.i18n.t('Failed to load plugin')}
|
||||||
subTitle={app.i18n.t(error?.message)}
|
subTitle={app.i18n.t(app.error?.message)}
|
||||||
extra={[
|
extra={[
|
||||||
<Button type="primary" key="try" onClick={() => window.location.reload()}>
|
<Button type="primary" key="try" onClick={() => window.location.reload()}>
|
||||||
{app.i18n.t('Try again')}
|
{app.i18n.t('Try again')}
|
||||||
@ -42,13 +48,142 @@ const AppError: FC<{ app: Application; error: Error }> = ({ app, error }) => (
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 {
|
export class NocoBaseBuildInPlugin extends Plugin {
|
||||||
async afterAdd() {
|
async afterAdd() {
|
||||||
this.app.addComponents({
|
this.app.addComponents({
|
||||||
AppSpin,
|
AppSpin,
|
||||||
AppError,
|
AppError,
|
||||||
|
AppMaintaining,
|
||||||
|
AppMaintainingDialog,
|
||||||
});
|
});
|
||||||
await this.addPlugins();
|
await this.addPlugins();
|
||||||
}
|
}
|
||||||
|
@ -10,12 +10,13 @@ export class LocalePlugin extends Plugin {
|
|||||||
const api = this.app.apiClient;
|
const api = this.app.apiClient;
|
||||||
const locale = api.auth.locale;
|
const locale = api.auth.locale;
|
||||||
try {
|
try {
|
||||||
const { data } = await api.request({
|
const res = await api.request({
|
||||||
url: 'app:getLang',
|
url: 'app:getLang',
|
||||||
params: {
|
params: {
|
||||||
locale,
|
locale,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const data = res?.data;
|
||||||
this.locales = data?.data || {};
|
this.locales = data?.data || {};
|
||||||
this.app.use(ConfigProvider, { locale: this.locales.antd, popupMatchSelectWidth: false });
|
this.app.use(ConfigProvider, { locale: this.locales.antd, popupMatchSelectWidth: false });
|
||||||
if (data?.data?.lang && !locale) {
|
if (data?.data?.lang && !locale) {
|
||||||
|
@ -247,7 +247,7 @@ export const PluginCard = (props: { data: IPluginData }) => {
|
|||||||
url: `pm:remove/${name}`,
|
url: `pm:remove/${name}`,
|
||||||
});
|
});
|
||||||
message.success(t('插件删除成功'));
|
message.success(t('插件删除成功'));
|
||||||
window.location.reload();
|
// window.location.reload();
|
||||||
}}
|
}}
|
||||||
onCancel={(e) => e.stopPropagation()}
|
onCancel={(e) => e.stopPropagation()}
|
||||||
okText={t('Yes')}
|
okText={t('Yes')}
|
||||||
@ -260,19 +260,19 @@ export const PluginCard = (props: { data: IPluginData }) => {
|
|||||||
size={'small'}
|
size={'small'}
|
||||||
onChange={async (checked, e) => {
|
onChange={async (checked, e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
modal.warning({
|
// modal.warning({
|
||||||
title: checked ? t('Plugin starting') : t('Plugin stopping'),
|
// title: checked ? t('Plugin starting') : t('Plugin stopping'),
|
||||||
content: t('The application is reloading, please do not close the page.'),
|
// content: t('The application is reloading, please do not close the page.'),
|
||||||
okButtonProps: {
|
// okButtonProps: {
|
||||||
style: {
|
// style: {
|
||||||
display: 'none',
|
// display: 'none',
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
await api.request({
|
await api.request({
|
||||||
url: `pm:${checked ? 'enable' : 'disable'}/${name}`,
|
url: `pm:${checked ? 'enable' : 'disable'}/${name}`,
|
||||||
});
|
});
|
||||||
window.location.reload();
|
// window.location.reload();
|
||||||
// message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
|
// message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
|
||||||
}}
|
}}
|
||||||
defaultChecked={enabled}
|
defaultChecked={enabled}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { css } from '@emotion/css';
|
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 React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Outlet, useMatch, useNavigate, useParams } from 'react-router-dom';
|
import { Outlet, useMatch, useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@ -22,6 +22,7 @@ import {
|
|||||||
useToken,
|
useToken,
|
||||||
} from '../../../';
|
} from '../../../';
|
||||||
import { Plugin } from '../../../application/Plugin';
|
import { Plugin } from '../../../application/Plugin';
|
||||||
|
import { useAppSpin } from '../../../application/hooks/useAppSpin';
|
||||||
import { useCollectionManager } from '../../../collection-manager';
|
import { useCollectionManager } from '../../../collection-manager';
|
||||||
|
|
||||||
const filterByACL = (schema, options) => {
|
const filterByACL = (schema, options) => {
|
||||||
@ -74,7 +75,7 @@ const MenuEditor = (props) => {
|
|||||||
setCurrent(schema);
|
setCurrent(schema);
|
||||||
navigate(`/admin/${schema['x-uid']}`);
|
navigate(`/admin/${schema['x-uid']}`);
|
||||||
};
|
};
|
||||||
|
const { render } = useAppSpin();
|
||||||
const adminSchemaUid = useAdminSchemaUid();
|
const adminSchemaUid = useAdminSchemaUid();
|
||||||
const { data, loading } = useRequest<{
|
const { data, loading } = useRequest<{
|
||||||
data: any;
|
data: any;
|
||||||
@ -140,7 +141,7 @@ const MenuEditor = (props) => {
|
|||||||
return s;
|
return s;
|
||||||
}, [data?.data]);
|
}, [data?.data]);
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Spin />;
|
return render();
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<SchemaIdContext.Provider value={defaultSelectedUid}>
|
<SchemaIdContext.Provider value={defaultSelectedUid}>
|
||||||
@ -155,6 +156,7 @@ export const InternalAdminLayout = (props: any) => {
|
|||||||
const { service } = useCollectionManager();
|
const { service } = useCollectionManager();
|
||||||
const params = useParams<any>();
|
const params = useParams<any>();
|
||||||
const { token } = useToken();
|
const { token } = useToken();
|
||||||
|
const { render } = useAppSpin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
@ -299,7 +301,7 @@ export const InternalAdminLayout = (props: any) => {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
`}
|
`}
|
||||||
></header>
|
></header>
|
||||||
{service.contentLoading ? <Spin /> : <Outlet />}
|
{service.contentLoading ? render() : <Outlet />}
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
@ -2,13 +2,14 @@ import { PlusOutlined } from '@ant-design/icons';
|
|||||||
import { PageHeader as AntdPageHeader } from '@ant-design/pro-layout';
|
import { PageHeader as AntdPageHeader } from '@ant-design/pro-layout';
|
||||||
import { FormLayout } from '@formily/antd-v5';
|
import { FormLayout } from '@formily/antd-v5';
|
||||||
import { Schema, SchemaOptionsContext, useFieldSchema } from '@formily/react';
|
import { Schema, SchemaOptionsContext, useFieldSchema } from '@formily/react';
|
||||||
import { Button, Spin, Tabs } from 'antd';
|
import { Button, Tabs } from 'antd';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { FormDialog } from '..';
|
import { FormDialog } from '..';
|
||||||
|
import { useAppSpin } from '../../../application/hooks/useAppSpin';
|
||||||
import { useDocumentTitle } from '../../../document-title';
|
import { useDocumentTitle } from '../../../document-title';
|
||||||
import { FilterBlockProvider } from '../../../filter-provider/FilterProvider';
|
import { FilterBlockProvider } from '../../../filter-provider/FilterProvider';
|
||||||
import { useGlobalTheme } from '../../../global-theme';
|
import { useGlobalTheme } from '../../../global-theme';
|
||||||
@ -194,9 +195,10 @@ function PageContent(
|
|||||||
props: any,
|
props: any,
|
||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
const { token } = useToken();
|
const { token } = useToken();
|
||||||
|
const { render } = useAppSpin();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Spin />;
|
return render();
|
||||||
}
|
}
|
||||||
|
|
||||||
return !disablePageHeader && enablePageTabs ? (
|
return !disablePageHeader && enablePageTabs ? (
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { ISchema, useFieldSchema } from '@formily/react';
|
import { ISchema, useFieldSchema } from '@formily/react';
|
||||||
import { uid } from '@formily/shared';
|
import { uid } from '@formily/shared';
|
||||||
import { Spin } from 'antd';
|
|
||||||
import { cloneDeep } from 'lodash';
|
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 { useAPIClient, useRequest } from '../api-client';
|
||||||
import { Plugin } from '../application/Plugin';
|
import { Plugin } from '../application/Plugin';
|
||||||
|
import { useAppSpin } from '../application/hooks/useAppSpin';
|
||||||
import { useCollectionManager } from '../collection-manager';
|
import { useCollectionManager } from '../collection-manager';
|
||||||
import { BlockTemplate } from './BlockTemplate';
|
import { BlockTemplate } from './BlockTemplate';
|
||||||
|
|
||||||
@ -116,6 +116,7 @@ export const useSchemaTemplateManager = () => {
|
|||||||
|
|
||||||
export const RemoteSchemaTemplateManagerProvider: React.FC<{ children?: ReactNode }> = (props) => {
|
export const RemoteSchemaTemplateManagerProvider: React.FC<{ children?: ReactNode }> = (props) => {
|
||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
|
const { render } = useAppSpin();
|
||||||
const options = {
|
const options = {
|
||||||
resource: 'uiSchemaTemplates',
|
resource: 'uiSchemaTemplates',
|
||||||
action: 'list',
|
action: 'list',
|
||||||
@ -128,7 +129,7 @@ export const RemoteSchemaTemplateManagerProvider: React.FC<{ children?: ReactNod
|
|||||||
data: any[];
|
data: any[];
|
||||||
}>(options);
|
}>(options);
|
||||||
if (service.loading) {
|
if (service.loading) {
|
||||||
return <Spin />;
|
return render();
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<SchemaTemplateManagerProvider
|
<SchemaTemplateManagerProvider
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Result } from 'ahooks/es/useRequest/src/types';
|
import { Result } from 'ahooks/es/useRequest/src/types';
|
||||||
import { Spin } from 'antd';
|
|
||||||
import React, { createContext, ReactNode, useContext } from 'react';
|
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);
|
export const SystemSettingsContext = createContext<Result<any, any>>(null);
|
||||||
|
|
||||||
@ -10,11 +10,12 @@ export const useSystemSettings = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SystemSettingsProvider: React.FC<{ children?: ReactNode }> = (props) => {
|
export const SystemSettingsProvider: React.FC<{ children?: ReactNode }> = (props) => {
|
||||||
|
const { render } = useAppSpin();
|
||||||
const result = useRequest({
|
const result = useRequest({
|
||||||
url: 'systemSettings:get/1?appends=logo',
|
url: 'systemSettings:get/1?appends=logo',
|
||||||
});
|
});
|
||||||
if (result.loading) {
|
if (result.loading) {
|
||||||
return <Spin />;
|
return render();
|
||||||
}
|
}
|
||||||
return <SystemSettingsContext.Provider value={result}>{props.children}</SystemSettingsContext.Provider>;
|
return <SystemSettingsContext.Provider value={result}>{props.children}</SystemSettingsContext.Provider>;
|
||||||
};
|
};
|
||||||
|
@ -78,19 +78,17 @@ export const SettingsMenu: React.FC<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'reboot',
|
key: 'reboot',
|
||||||
label: t('Reboot application'),
|
label: t('Restart application'),
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: t('Reboot application'),
|
title: t('Restart application'),
|
||||||
content: t('The will interrupt service, it may take a few seconds to restart. Are you sure to continue?'),
|
// content: t('The will interrupt service, it may take a few seconds to restart. Are you sure to continue?'),
|
||||||
okText: t('Reboot'),
|
okText: t('Restart'),
|
||||||
okButtonProps: {
|
okButtonProps: {
|
||||||
danger: true,
|
danger: true,
|
||||||
},
|
},
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
await api.resource('app').reboot();
|
await api.resource('app').restart();
|
||||||
await check();
|
|
||||||
window.location.reload();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Spin } from 'antd';
|
|
||||||
import React, { createContext, useContext, useMemo } from 'react';
|
import React, { createContext, useContext, useMemo } from 'react';
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useACLRoleContext } from '../acl';
|
import { useACLRoleContext } from '../acl';
|
||||||
import { ReturnTypeOfUseRequest, useRequest } from '../api-client';
|
import { ReturnTypeOfUseRequest, useRequest } from '../api-client';
|
||||||
|
import { useAppSpin } from '../application/hooks/useAppSpin';
|
||||||
import { useCompile } from '../schema-component';
|
import { useCompile } from '../schema-component';
|
||||||
|
|
||||||
export const CurrentUserContext = createContext<ReturnTypeOfUseRequest>(null);
|
export const CurrentUserContext = createContext<ReturnTypeOfUseRequest>(null);
|
||||||
@ -29,11 +29,12 @@ export const useCurrentRoles = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CurrentUserProvider = (props) => {
|
export const CurrentUserProvider = (props) => {
|
||||||
|
const { render } = useAppSpin();
|
||||||
const result = useRequest<any>({
|
const result = useRequest<any>({
|
||||||
url: 'auth:check',
|
url: 'auth:check',
|
||||||
});
|
});
|
||||||
if (result.loading) {
|
if (result.loading) {
|
||||||
return <Spin />;
|
return render();
|
||||||
}
|
}
|
||||||
return <CurrentUserContext.Provider value={result}>{props.children}</CurrentUserContext.Provider>;
|
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.API_BASE_URL': API_BASE_URL || API_BASE_PATH,
|
||||||
'process.env.APP_ENV': process.env.APP_ENV,
|
'process.env.APP_ENV': process.env.APP_ENV,
|
||||||
'process.env.VERSION': packageJson.version,
|
'process.env.VERSION': packageJson.version,
|
||||||
|
'process.env.WEBSOCKET_URL': process.env.WEBSOCKET_URL,
|
||||||
},
|
},
|
||||||
// only proxy when using `umi dev`
|
// only proxy when using `umi dev`
|
||||||
// if the assets are built, will not proxy
|
// if the assets are built, will not proxy
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
const getSubAppName = () => {
|
const getSubAppName = () => {
|
||||||
const match = window.location.pathname.match(/^\/apps\/([^/]*)\//);
|
const match = window.location.pathname.match(/^\/apps\/([^/]*)\/?/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return match[1].toUpperCase();
|
return match[1];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default getSubAppName;
|
export default getSubAppName;
|
||||||
|
@ -36,10 +36,15 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"multer": "^1.4.2",
|
"multer": "^1.4.2",
|
||||||
"semver": "^7.3.7",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/semver": "^7.3.9"
|
"@types/semver": "^7.3.9",
|
||||||
|
"@types/ws": "^8.5.5",
|
||||||
|
"@types/serve-handler": "^6.1.1"
|
||||||
},
|
},
|
||||||
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
|
"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 () => {
|
afterEach(async () => {
|
||||||
return app.db.close();
|
return app.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resourcer.define', async () => {
|
it('resourcer.define', async () => {
|
||||||
@ -126,5 +126,26 @@ describe('application', () => {
|
|||||||
expect(response.body).toEqual([1, 2]);
|
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);
|
const app = new Application(config);
|
||||||
|
|
||||||
|
await app.load();
|
||||||
|
|
||||||
const appPluginA = app.getPlugin('test-a');
|
const appPluginA = app.getPlugin('test-a');
|
||||||
|
|
||||||
expect(appPluginA).toBeInstanceOf(TestA);
|
expect(appPluginA).toBeInstanceOf(TestA);
|
||||||
|
@ -43,7 +43,7 @@ describe('application', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
return app.db.close();
|
return app.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resourcer.define', async () => {
|
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 () => {
|
afterEach(async () => {
|
||||||
return app.db.close();
|
return app.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('global', async () => {
|
it('global', async () => {
|
||||||
|
@ -2,14 +2,22 @@ import Application from '../application';
|
|||||||
import { Plugin } from '../plugin';
|
import { Plugin } from '../plugin';
|
||||||
|
|
||||||
describe('application life cycle', () => {
|
describe('application life cycle', () => {
|
||||||
it('should start application', async () => {
|
let app: Application;
|
||||||
const app = new Application({
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = new Application({
|
||||||
database: {
|
database: {
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
storage: ':memory:',
|
storage: ':memory:',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start application', async () => {
|
||||||
const loadFn = jest.fn();
|
const loadFn = jest.fn();
|
||||||
const installFn = jest.fn();
|
const installFn = jest.fn();
|
||||||
|
|
||||||
@ -35,19 +43,4 @@ describe('application life cycle', () => {
|
|||||||
await app.install();
|
await app.install();
|
||||||
expect(installFn).toHaveBeenCalledTimes(1);
|
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 { mockServer, MockServer } from '@nocobase/test';
|
||||||
import { uid } from '@nocobase/utils';
|
import { uid } from '@nocobase/utils';
|
||||||
import { IncomingMessage } from 'http';
|
|
||||||
import * as url from 'url';
|
|
||||||
import Application from '../application';
|
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', () => {
|
describe('multiple application', () => {
|
||||||
let app: MockServer;
|
let app: MockServer;
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@ -67,13 +20,11 @@ describe('multiple application', () => {
|
|||||||
const sub2 = `a_${uid()}`;
|
const sub2 = `a_${uid()}`;
|
||||||
const sub3 = `a_${uid()}`;
|
const sub3 = `a_${uid()}`;
|
||||||
|
|
||||||
const subApp1 = app.appManager.addSubApp(
|
const subApp1 = new Application({
|
||||||
new Application({
|
database: app.db,
|
||||||
database: app.db,
|
acl: false,
|
||||||
acl: false,
|
name: sub1,
|
||||||
name: sub1,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
subApp1.resourcer.define({
|
subApp1.resourcer.define({
|
||||||
name: 'test',
|
name: 'test',
|
||||||
@ -84,13 +35,11 @@ describe('multiple application', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const subApp2 = app.appManager.addSubApp(
|
const subApp2 = new Application({
|
||||||
new Application({
|
database: app.db,
|
||||||
database: app.db,
|
acl: false,
|
||||||
acl: false,
|
name: sub2,
|
||||||
name: sub2,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
subApp2.resourcer.define({
|
subApp2.resourcer.define({
|
||||||
name: 'test',
|
name: 'test',
|
||||||
@ -101,28 +50,8 @@ describe('multiple application', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = await app.agent().resource('test').test();
|
expect(AppSupervisor.getInstance().hasApp(sub1)).toBeTruthy();
|
||||||
expect(response.statusCode).toEqual(404);
|
expect(AppSupervisor.getInstance().hasApp(sub2)).toBeTruthy();
|
||||||
|
expect(AppSupervisor.getInstance().hasApp(sub3)).toBeFalsy();
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { Application } from '../application';
|
import { mockServer, MockServer } from '@nocobase/test';
|
||||||
import { Plugin } from '../plugin';
|
import { Plugin } from '../plugin';
|
||||||
import Plugin1 from './plugins/plugin1';
|
import Plugin1 from './plugins/plugin1';
|
||||||
import Plugin2 from './plugins/plugin2';
|
import Plugin2 from './plugins/plugin2';
|
||||||
import Plugin3 from './plugins/plugin3';
|
import Plugin3 from './plugins/plugin3';
|
||||||
import { mockServer, MockServer } from '@nocobase/test';
|
|
||||||
|
|
||||||
describe('plugin', () => {
|
describe('plugin', () => {
|
||||||
let app: MockServer;
|
let app: MockServer;
|
||||||
@ -19,57 +18,6 @@ describe('plugin', () => {
|
|||||||
await app.destroy();
|
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', () => {
|
describe('load', () => {
|
||||||
it('plugin load', async () => {
|
it('plugin load', async () => {
|
||||||
app.plugin(
|
app.plugin(
|
||||||
@ -112,7 +60,7 @@ describe('plugin', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('enable', function () {
|
describe.skip('enable', function () {
|
||||||
it('should call beforeEnable', async () => {
|
it('should call beforeEnable', async () => {
|
||||||
const beforeEnable = jest.fn();
|
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 { registerActions } from '@nocobase/actions';
|
||||||
import { actions as authActions, AuthManager } from '@nocobase/auth';
|
import { actions as authActions, AuthManager } from '@nocobase/auth';
|
||||||
import { Cache, createCache, ICacheConfig } from '@nocobase/cache';
|
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 { AppLoggerOptions, createAppLogger, Logger } from '@nocobase/logger';
|
||||||
import Resourcer, { ResourceOptions } from '@nocobase/resourcer';
|
import Resourcer, { ResourceOptions } from '@nocobase/resourcer';
|
||||||
import { applyMixins, AsyncEmitter, Toposort, ToposortOptions } from '@nocobase/utils';
|
import { applyMixins, AsyncEmitter, Toposort, ToposortOptions } from '@nocobase/utils';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { Command, CommandOptions, ParseOptions } from 'commander';
|
import { Command, CommandOptions, ParseOptions } from 'commander';
|
||||||
import { Server } from 'http';
|
import { IncomingMessage, Server, ServerResponse } from 'http';
|
||||||
import { i18n, InitOptions } from 'i18next';
|
import { i18n, InitOptions } from 'i18next';
|
||||||
import Koa, { DefaultContext as KoaDefaultContext, DefaultState as KoaDefaultState } from 'koa';
|
import Koa, { DefaultContext as KoaDefaultContext, DefaultState as KoaDefaultState } from 'koa';
|
||||||
import compose from 'koa-compose';
|
import compose from 'koa-compose';
|
||||||
import semver from 'semver';
|
import lodash from 'lodash';
|
||||||
import { promisify } from 'util';
|
|
||||||
import { createACL } from './acl';
|
import { createACL } from './acl';
|
||||||
import { AppManager } from './app-manager';
|
import { AppSupervisor } from './app-supervisor';
|
||||||
import { registerCli } from './commands';
|
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 { Locale } from './locale';
|
||||||
import { Plugin } from './plugin';
|
import { Plugin } from './plugin';
|
||||||
import { InstallOptions, PluginManager } from './plugin-manager';
|
import { InstallOptions, PluginManager } from './plugin-manager';
|
||||||
|
|
||||||
const packageJson = require('../package.json');
|
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 {
|
export interface ResourcerOptions {
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
@ -61,14 +63,6 @@ export interface DefaultContext extends KoaDefaultContext {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MiddlewareOptions {
|
|
||||||
name?: string;
|
|
||||||
resourceName?: string;
|
|
||||||
resourceNames?: string[];
|
|
||||||
insertBefore?: string;
|
|
||||||
insertAfter?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActionsOptions {
|
interface ActionsOptions {
|
||||||
resourceName?: string;
|
resourceName?: string;
|
||||||
resourceNames?: string[];
|
resourceNames?: string[];
|
||||||
@ -92,251 +86,169 @@ interface ListenOptions {
|
|||||||
interface StartOptions {
|
interface StartOptions {
|
||||||
cliArgs?: any[];
|
cliArgs?: any[];
|
||||||
dbSync?: boolean;
|
dbSync?: boolean;
|
||||||
listen?: ListenOptions;
|
checkInstall?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApplicationVersion {
|
type MaintainingStatus = 'command_begin' | 'command_end' | 'command_running' | 'command_error';
|
||||||
protected app: Application;
|
|
||||||
protected collection: Collection;
|
|
||||||
|
|
||||||
constructor(app: Application) {
|
export type MaintainingCommandStatus = {
|
||||||
this.app = app;
|
command: {
|
||||||
if (!app.db.hasCollection('applicationVersion')) {
|
name: string;
|
||||||
app.db.collection({
|
};
|
||||||
name: 'applicationVersion',
|
status: MaintainingStatus;
|
||||||
namespace: 'core.applicationVersion',
|
error?: Error;
|
||||||
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 class Application<StateT = DefaultState, ContextT = DefaultContext> extends Koa implements AsyncEmitter {
|
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;
|
public listenServer: Server;
|
||||||
|
|
||||||
declare middleware: any;
|
declare middleware: any;
|
||||||
|
|
||||||
stopped = false;
|
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) {
|
constructor(public options: ApplicationOptions) {
|
||||||
super();
|
super();
|
||||||
|
this.rawOptions = this.name == 'main' ? lodash.cloneDeep(options) : {};
|
||||||
this.init();
|
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() {
|
get db() {
|
||||||
return this._db;
|
return this._db;
|
||||||
}
|
}
|
||||||
|
|
||||||
get cache() {
|
protected _logger: Logger;
|
||||||
return this._cache;
|
|
||||||
|
get logger() {
|
||||||
|
return this._logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _resourcer: Resourcer;
|
||||||
|
|
||||||
get resourcer() {
|
get resourcer() {
|
||||||
return this._resourcer;
|
return this._resourcer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _cache: Cache;
|
||||||
|
|
||||||
|
get cache() {
|
||||||
|
return this._cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _cli: Command;
|
||||||
|
|
||||||
get cli() {
|
get cli() {
|
||||||
return this._cli;
|
return this._cli;
|
||||||
}
|
}
|
||||||
|
|
||||||
get acl() {
|
protected _i18n: i18n;
|
||||||
return this._acl;
|
|
||||||
}
|
|
||||||
|
|
||||||
get i18n() {
|
get i18n() {
|
||||||
return this._i18n;
|
return this._i18n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _pm: PluginManager;
|
||||||
|
|
||||||
get pm() {
|
get pm() {
|
||||||
return this._pm;
|
return this._pm;
|
||||||
}
|
}
|
||||||
|
|
||||||
get version() {
|
protected _acl: ACL;
|
||||||
return this._version;
|
|
||||||
|
get acl() {
|
||||||
|
return this._acl;
|
||||||
}
|
}
|
||||||
|
|
||||||
get appManager() {
|
protected _authManager: AuthManager;
|
||||||
return this._appManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
get authManager() {
|
get authManager() {
|
||||||
return this._authManager;
|
return this._authManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
get logger() {
|
protected _locales: Locale;
|
||||||
return this._logger;
|
|
||||||
|
get locales() {
|
||||||
|
return this._locales;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _version: ApplicationVersion;
|
||||||
|
|
||||||
|
get version() {
|
||||||
|
return this._version;
|
||||||
}
|
}
|
||||||
|
|
||||||
get log() {
|
get log() {
|
||||||
return this._logger;
|
return this._logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
get locales() {
|
|
||||||
return this._locales;
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
return this.options.name || 'main';
|
return this.options.name || 'main';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected init() {
|
isMaintaining() {
|
||||||
const options = this.options;
|
return this._maintaining;
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
this._db = this.createDatabase(options);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (this._pm) {
|
|
||||||
this._pm = this._pm.clone();
|
|
||||||
} else {
|
|
||||||
this._pm = new PluginManager({
|
|
||||||
app: this,
|
|
||||||
plugins: options.plugins,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
getMaintaining() {
|
||||||
const db = new Database({
|
return this._maintainingCommandStatus;
|
||||||
...(options.database instanceof Database ? options.database.options : options.database),
|
}
|
||||||
migrator: {
|
|
||||||
context: { app: this },
|
setMaintaining(_maintainingCommandStatus: MaintainingCommandStatus) {
|
||||||
},
|
this._maintainingCommandStatus = _maintainingCommandStatus;
|
||||||
|
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
db.setLogger(this._logger);
|
|
||||||
|
|
||||||
return db;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getVersion() {
|
getVersion() {
|
||||||
return packageJson.version;
|
return packageJson.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
plugin<O = any>(pluginClass: any, options?: O): Plugin {
|
plugin<O = any>(pluginClass: any, options?: O) {
|
||||||
return this.pm.addStatic(pluginClass, options);
|
this.log.debug(`add plugin ${pluginClass.name}`);
|
||||||
|
this.pm.addPreset(pluginClass, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -353,14 +265,12 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
|
|
||||||
if (!this.listenerCount('error')) this.on('error', this.onerror);
|
if (!this.listenerCount('error')) this.on('error', this.onerror);
|
||||||
|
|
||||||
const handleRequest = (req, res) => {
|
return (req: IncomingMessage, res: ServerResponse) => {
|
||||||
const ctx = this.createContext(req, res);
|
const ctx = this.createContext(req, res);
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
return this.handleRequest(ctx, fn);
|
return this.handleRequest(ctx, fn);
|
||||||
};
|
};
|
||||||
|
|
||||||
return handleRequest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
collection(options: CollectionOptions) {
|
collection(options: CollectionOptions) {
|
||||||
@ -380,32 +290,54 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
}
|
}
|
||||||
|
|
||||||
findCommand(name: string): Command {
|
findCommand(name: string): Command {
|
||||||
return (<any>this.cli)._findCommand(name);
|
return (this.cli as any)._findCommand(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(options?: any) {
|
async load(options?: any) {
|
||||||
|
if (this._loaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (options?.reload) {
|
if (options?.reload) {
|
||||||
console.log(`Reload the ${this.name} application configuration`);
|
this.setMaintainingMessage('app reload');
|
||||||
|
this.log.info(`app.reload()`);
|
||||||
const oldDb = this._db;
|
const oldDb = this._db;
|
||||||
this.init();
|
this.init();
|
||||||
await oldDb.close();
|
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.emitAsync('beforeLoad', this, options);
|
||||||
|
|
||||||
await this.pm.load(options);
|
await this.pm.load(options);
|
||||||
|
|
||||||
|
this.setMaintainingMessage('emit afterLoad');
|
||||||
await this.emitAsync('afterLoad', this, options);
|
await this.emitAsync('afterLoad', this, options);
|
||||||
|
this._loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reload(options?: any) {
|
async reload(options?: any) {
|
||||||
|
this.log.debug(`start reload`);
|
||||||
|
|
||||||
|
this._loaded = false;
|
||||||
|
|
||||||
await this.load({
|
await this.load({
|
||||||
...options,
|
...options,
|
||||||
reload: true,
|
reload: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.log.debug('emit afterReload');
|
||||||
|
this.setMaintainingMessage('emit afterReload');
|
||||||
await this.emitAsync('afterReload', this, options);
|
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;
|
return this.pm.get(name) as P;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -413,75 +345,129 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
return this.runAsCLI(argv);
|
return this.runAsCLI(argv);
|
||||||
}
|
}
|
||||||
|
|
||||||
async runAsCLI(argv = process.argv, options?: ParseOptions) {
|
async authenticate() {
|
||||||
try {
|
if (this._authenticated) {
|
||||||
await this.db.auth({ retry: 30 });
|
return;
|
||||||
} catch (error) {
|
|
||||||
console.log(chalk.red(error.message));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
this._authenticated = true;
|
||||||
|
await this.db.auth({ retry: 30 });
|
||||||
await this.dbVersionCheck({ exit: true });
|
await this.dbVersionCheck({ exit: true });
|
||||||
|
|
||||||
await this.db.prepare();
|
await this.db.prepare();
|
||||||
|
}
|
||||||
|
|
||||||
if (argv?.[2] !== 'upgrade') {
|
async runCommand(command: string, ...args: any[]) {
|
||||||
await this.load({
|
return await this.runAsCLI([command, ...args], { from: 'user' });
|
||||||
method: argv?.[2],
|
}
|
||||||
|
|
||||||
|
createCli() {
|
||||||
|
return new Command('nocobase')
|
||||||
|
.usage('[command] [options]')
|
||||||
|
.hook('preAction', async (_, actionCommand) => {
|
||||||
|
this.activatedCommand = {
|
||||||
|
name: getCommandFullName(actionCommand),
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async runAsCLI(argv = process.argv, options?: ParseOptions) {
|
||||||
|
if (this.activatedCommand) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.cli.parseAsync(argv, options);
|
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 = {}) {
|
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()) {
|
if (this.db.closed()) {
|
||||||
await this.db.reconnect();
|
await this.db.reconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.dbSync) {
|
this.setMaintainingMessage('emit beforeStart');
|
||||||
console.log('db sync...');
|
|
||||||
await this.db.sync();
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.emitAsync('beforeStart', this, options);
|
await this.emitAsync('beforeStart', this, options);
|
||||||
|
|
||||||
if (options?.listen?.port) {
|
this.setMaintainingMessage('emit afterStart');
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.emitAsync('afterStart', this, options);
|
await this.emitAsync('afterStart', this, options);
|
||||||
|
await this.emitAsync('__started', this, options);
|
||||||
this.stopped = false;
|
this.stopped = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
listen(...args): Server {
|
async isStarted() {
|
||||||
return this.appManager.listen(...args);
|
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 = {}) {
|
async stop(options: any = {}) {
|
||||||
|
this.log.debug('stop app...');
|
||||||
|
this.setMaintainingMessage('stopping app...');
|
||||||
if (this.stopped) {
|
if (this.stopped) {
|
||||||
this.log.warn(`Application ${this.name} already stopped`);
|
this.log.warn(`Application ${this.name} already stopped`);
|
||||||
return;
|
return;
|
||||||
@ -489,31 +475,33 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
|
|
||||||
await this.emitAsync('beforeStop', this, options);
|
await this.emitAsync('beforeStop', this, options);
|
||||||
|
|
||||||
// close http server
|
|
||||||
if (this.listenServer) {
|
|
||||||
await promisify(this.listenServer.close).call(this.listenServer);
|
|
||||||
this.listenServer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// close database connection
|
// close database connection
|
||||||
// silent if database already closed
|
// silent if database already closed
|
||||||
if (!this.db.closed()) {
|
if (!this.db.closed()) {
|
||||||
|
this.logger.info(`close db`);
|
||||||
await this.db.close();
|
await this.db.close();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
this.log.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.emitAsync('afterStop', this, options);
|
await this.emitAsync('afterStop', this, options);
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
console.log(`${this.name} is stopped`);
|
this.log.info(`${this.name} is stopped`);
|
||||||
|
this._started = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroy(options: any = {}) {
|
async destroy(options: any = {}) {
|
||||||
|
this.logger.debug('start destroy app');
|
||||||
|
this.setMaintainingMessage('destroying app...');
|
||||||
await this.emitAsync('beforeDestroy', this, options);
|
await this.emitAsync('beforeDestroy', this, options);
|
||||||
await this.stop(options);
|
await this.stop(options);
|
||||||
|
|
||||||
|
this.logger.debug('emit afterDestroy');
|
||||||
await this.emitAsync('afterDestroy', this, options);
|
await this.emitAsync('afterDestroy', this, options);
|
||||||
|
|
||||||
|
this.logger.debug('finish destroy app');
|
||||||
}
|
}
|
||||||
|
|
||||||
async dbVersionCheck(options?: { exit?: boolean }) {
|
async dbVersionCheck(options?: { exit?: boolean }) {
|
||||||
@ -554,19 +542,37 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
}
|
}
|
||||||
|
|
||||||
async install(options: InstallOptions = {}) {
|
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) {
|
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.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.emitAsync('beforeInstall', this, options);
|
||||||
await this.db.sync();
|
this.log.debug('start install plugins');
|
||||||
await this.pm.install(options);
|
await this.pm.install(options);
|
||||||
|
this.log.debug('update version');
|
||||||
await this.version.update();
|
await this.version.update();
|
||||||
|
this.log.debug('emit afterInstall');
|
||||||
|
this.setMaintainingMessage('call afterInstall hook...');
|
||||||
await this.emitAsync('afterInstall', this, options);
|
await this.emitAsync('afterInstall', this, options);
|
||||||
|
|
||||||
|
if (this._maintainingStatusBeforeCommand?.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._started) {
|
||||||
|
await this.restart();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async upgrade(options: any = {}) {
|
async upgrade(options: any = {}) {
|
||||||
@ -581,15 +587,113 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
});
|
});
|
||||||
await this.version.update();
|
await this.version.update();
|
||||||
await this.emitAsync('afterUpgrade', this, options);
|
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() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
appName: this.name,
|
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]);
|
applyMixins(Application, [AsyncEmitter]);
|
||||||
|
@ -11,8 +11,5 @@ export default (app: Application) => {
|
|||||||
drop: force,
|
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('./install').default(app);
|
||||||
require('./migrator').default(app);
|
require('./migrator').default(app);
|
||||||
require('./start').default(app);
|
require('./start').default(app);
|
||||||
|
require('./restart').default(app);
|
||||||
|
require('./stop').default(app);
|
||||||
|
require('./destroy').default(app);
|
||||||
require('./upgrade').default(app);
|
require('./upgrade').default(app);
|
||||||
require('./pm').default(app);
|
require('./pm').default(app);
|
||||||
|
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import chalk from 'chalk';
|
|
||||||
import Application from '../application';
|
import Application from '../application';
|
||||||
|
|
||||||
export default (app: Application) => {
|
export default (app: Application) => {
|
||||||
@ -6,40 +5,8 @@ export default (app: Application) => {
|
|||||||
.command('install')
|
.command('install')
|
||||||
.option('-f, --force')
|
.option('-f, --force')
|
||||||
.option('-c, --clean')
|
.option('-c, --clean')
|
||||||
.option('-s, --silent')
|
|
||||||
.option('-r, --retry [retry]')
|
|
||||||
.option('-I, --ignore-installed')
|
|
||||||
.action(async (...cliArgs) => {
|
.action(async (...cliArgs) => {
|
||||||
let installed = false;
|
|
||||||
const [opts] = cliArgs;
|
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({
|
await app.install({
|
||||||
cliArgs,
|
cliArgs,
|
||||||
clean: opts.clean,
|
clean: opts.clean,
|
||||||
@ -47,9 +14,5 @@ export default (app: Application) => {
|
|||||||
force: opts.force,
|
force: opts.force,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.stop({
|
|
||||||
cliArgs,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1,18 +1,51 @@
|
|||||||
import Application from '../application';
|
import Application from '../application';
|
||||||
|
|
||||||
export default (app: Application) => {
|
export default (app: Application) => {
|
||||||
app
|
const pm = app.command('pm');
|
||||||
.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']);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
export default (app: Application) => {
|
||||||
app
|
app
|
||||||
.command('start')
|
.command('start')
|
||||||
.option('-s, --silent')
|
|
||||||
.option('-p, --port [post]')
|
|
||||||
.option('-h, --host [host]')
|
|
||||||
.option('--db-sync')
|
.option('--db-sync')
|
||||||
|
.option('--quickstart')
|
||||||
.action(async (...cliArgs) => {
|
.action(async (...cliArgs) => {
|
||||||
const [opts] = 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({
|
await app.start({
|
||||||
dbSync: opts?.dbSync,
|
dbSync: opts?.dbSync,
|
||||||
cliArgs,
|
cliArgs,
|
||||||
listen: {
|
checkInstall: true,
|
||||||
port,
|
|
||||||
host,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
const [opts] = cliArgs;
|
||||||
console.log('upgrading...');
|
console.log('upgrading...');
|
||||||
await app.upgrade();
|
await app.upgrade();
|
||||||
await app.stop({
|
|
||||||
cliArgs,
|
|
||||||
});
|
|
||||||
console.log(chalk.green(`✨ NocoBase has been upgraded to v${app.getVersion()}`));
|
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 cors from '@koa/cors';
|
||||||
import Database from '@nocobase/database';
|
import Database from '@nocobase/database';
|
||||||
import Resourcer from '@nocobase/resourcer';
|
import Resourcer from '@nocobase/resourcer';
|
||||||
|
import { Command } from 'commander';
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
import bodyParser from 'koa-bodyparser';
|
import bodyParser from 'koa-bodyparser';
|
||||||
import Application, { ApplicationOptions } from './application';
|
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(db2resource, { tag: 'db2resource', after: 'dataWrapping' });
|
||||||
app.use(app.resourcer.restApiMiddleware(), { tag: 'restApi', after: 'db2resource' });
|
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 * from './application';
|
||||||
export { Application as default } from './application';
|
export { Application as default } from './application';
|
||||||
export * as middlewares from './middlewares';
|
export * as middlewares from './middlewares';
|
||||||
@ -6,3 +5,5 @@ export * from './migration';
|
|||||||
export * from './plugin';
|
export * from './plugin';
|
||||||
export * from './plugin-manager';
|
export * from './plugin-manager';
|
||||||
export * from './read-config';
|
export * from './read-config';
|
||||||
|
export * from './gateway';
|
||||||
|
export * from './app-supervisor';
|
||||||
|
@ -14,11 +14,17 @@ export class Locale {
|
|||||||
this.app = app;
|
this.app = app;
|
||||||
this.cache = createCache();
|
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() {
|
async load() {
|
||||||
this.getCacheResources(this.defaultLang);
|
await this.get(this.defaultLang);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocaleFn(name: string, fn: (lang: string) => Promise<any>) {
|
setLocaleFn(name: string, fn: (lang: string) => Promise<any>) {
|
||||||
@ -30,6 +36,7 @@ export class Locale {
|
|||||||
resources: await this.getCacheResources(lang),
|
resources: await this.getCacheResources(lang),
|
||||||
};
|
};
|
||||||
for (const [name, fn] of this.localeFn) {
|
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));
|
const result = await this.wrapCache(`locale:${name}:${lang}`, async () => await fn(lang));
|
||||||
if (result) {
|
if (result) {
|
||||||
defaults[name] = result;
|
defaults[name] = result;
|
||||||
@ -57,10 +64,12 @@ export class Locale {
|
|||||||
|
|
||||||
getResources(lang: string) {
|
getResources(lang: string) {
|
||||||
const resources = {};
|
const resources = {};
|
||||||
const plugins = this.app.pm.getPlugins();
|
const names = this.app.pm.getAliases();
|
||||||
for (const name of plugins.keys()) {
|
for (const name of names) {
|
||||||
try {
|
try {
|
||||||
const packageName = PluginManager.getPackageName(name);
|
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);
|
const res = getResource(packageName, lang);
|
||||||
if (res) {
|
if (res) {
|
||||||
resources[name] = { ...res };
|
resources[name] = { ...res };
|
||||||
@ -69,10 +78,6 @@ export class Locale {
|
|||||||
// empty
|
// empty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const res = getResource('@nocobase/client', lang, false);
|
|
||||||
if (res) {
|
|
||||||
resources['client'] = { ...(resources['client'] || {}), ...res };
|
|
||||||
}
|
|
||||||
return resources;
|
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 PREFIX = '/api/plugins/client/';
|
||||||
const cwd = process.cwd();
|
|
||||||
const NODE_MODULES = path.join(cwd, 'node_modules');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get plugin client static file url
|
* 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) => {
|
export const getPackageClientStaticUrl = (packageName: string, filePath: string) => {
|
||||||
return `${PREFIX}${packageName}/${filePath}`;
|
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 {
|
export default {
|
||||||
name: 'pm',
|
name: 'pm',
|
||||||
actions: {
|
actions: {
|
||||||
@ -14,10 +16,11 @@ export default {
|
|||||||
async enable(ctx, next) {
|
async enable(ctx, next) {
|
||||||
const pm = ctx.app.pm;
|
const pm = ctx.app.pm;
|
||||||
const { filterByTk } = ctx.action.params;
|
const { filterByTk } = ctx.action.params;
|
||||||
|
const app = ctx.app as Application;
|
||||||
if (!filterByTk) {
|
if (!filterByTk) {
|
||||||
ctx.throw(400, 'plugin name invalid');
|
ctx.throw(400, 'plugin name invalid');
|
||||||
}
|
}
|
||||||
await pm.enable(filterByTk);
|
app.runAsCLI(['pm', 'enable', filterByTk], { from: 'user' });
|
||||||
ctx.body = filterByTk;
|
ctx.body = filterByTk;
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
@ -27,7 +30,8 @@ export default {
|
|||||||
if (!filterByTk) {
|
if (!filterByTk) {
|
||||||
ctx.throw(400, 'plugin name invalid');
|
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;
|
ctx.body = filterByTk;
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
@ -41,7 +45,8 @@ export default {
|
|||||||
if (!filterByTk) {
|
if (!filterByTk) {
|
||||||
ctx.throw(400, 'plugin name invalid');
|
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;
|
ctx.body = filterByTk;
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Repository } from '@nocobase/database';
|
import { Repository } from '@nocobase/database';
|
||||||
|
import lodash from 'lodash';
|
||||||
import { PluginManager } from './plugin-manager';
|
import { PluginManager } from './plugin-manager';
|
||||||
|
|
||||||
export class PluginManagerRepository extends Repository {
|
export class PluginManagerRepository extends Repository {
|
||||||
@ -17,13 +18,13 @@ export class PluginManagerRepository extends Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async enable(name: string | string[]) {
|
async enable(name: string | string[]) {
|
||||||
const pluginNames = typeof name === 'string' ? [name] : name;
|
const pluginNames = lodash.castArray(name);
|
||||||
const plugins = pluginNames.map((name) => this.pm.plugins.get(name));
|
const plugins = pluginNames.map((name) => this.pm.get(name));
|
||||||
|
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
const requiredPlugins = plugin.requiredPlugins();
|
const requiredPlugins = plugin.requiredPlugins();
|
||||||
for (const requiredPluginName of requiredPlugins) {
|
for (const requiredPluginName of requiredPlugins) {
|
||||||
const requiredPlugin = this.pm.plugins.get(requiredPluginName);
|
const requiredPlugin = this.pm.get(requiredPluginName);
|
||||||
if (!requiredPlugin.enabled) {
|
if (!requiredPlugin.enabled) {
|
||||||
throw new Error(`${plugin.name} plugin need ${requiredPluginName} plugin 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[]) {
|
async disable(name: string | string[]) {
|
||||||
const pluginNames = typeof name === 'string' ? [name] : name;
|
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({
|
await this.update({
|
||||||
filter: {
|
filter,
|
||||||
name,
|
|
||||||
},
|
|
||||||
values: {
|
values: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
installed: false,
|
installed: false,
|
||||||
@ -60,19 +67,38 @@ export class PluginManagerRepository extends Repository {
|
|||||||
return pluginNames;
|
return pluginNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async getItems() {
|
||||||
// sort plugins by id
|
try {
|
||||||
const items = await this.find({
|
// sort plugins by id
|
||||||
sort: 'id',
|
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) {
|
for (const item of items) {
|
||||||
await this.pm.addStatic(item.get('name'), {
|
const { options, ...others } = item.toJSON();
|
||||||
...item.get('options'),
|
await this.pm.add(item.get('name'), {
|
||||||
name: item.get('name'),
|
...others,
|
||||||
version: item.get('version'),
|
...options,
|
||||||
enabled: item.get('enabled'),
|
|
||||||
async: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import { CleanOptions, Collection, SyncOptions } from '@nocobase/database';
|
import { CleanOptions, Collection, SyncOptions } from '@nocobase/database';
|
||||||
import { requireModule } from '@nocobase/utils';
|
import { requireModule } from '@nocobase/utils';
|
||||||
import execa from 'execa';
|
import execa from 'execa';
|
||||||
import fs from 'fs';
|
import _ from 'lodash';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import xpipe from 'xpipe';
|
|
||||||
import Application from '../application';
|
import Application from '../application';
|
||||||
|
import { createAppProxy } from '../helper';
|
||||||
import { Plugin } from '../plugin';
|
import { Plugin } from '../plugin';
|
||||||
import { clientStaticMiddleware } from './clientStaticMiddleware';
|
|
||||||
import collectionOptions from './options/collection';
|
import collectionOptions from './options/collection';
|
||||||
import resourceOptions from './options/resource';
|
import resourceOptions from './options/resource';
|
||||||
import { PluginManagerRepository } from './plugin-manager-repository';
|
import { PluginManagerRepository } from './plugin-manager-repository';
|
||||||
@ -23,31 +22,28 @@ export interface InstallOptions {
|
|||||||
sync?: SyncOptions;
|
sync?: SyncOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AddPresetError extends Error {}
|
||||||
|
|
||||||
export class PluginManager {
|
export class PluginManager {
|
||||||
app: Application;
|
app: Application;
|
||||||
collection: Collection;
|
collection: Collection;
|
||||||
repository: PluginManagerRepository;
|
_repository: PluginManagerRepository;
|
||||||
plugins = new Map<string, Plugin>();
|
pluginInstances = new Map<typeof Plugin, Plugin>();
|
||||||
|
pluginAliases = new Map<string, Plugin>();
|
||||||
server: net.Server;
|
server: net.Server;
|
||||||
pmSock: string;
|
|
||||||
_tmpPluginArgs = [];
|
|
||||||
|
|
||||||
constructor(options: PluginManagerOptions) {
|
constructor(public options: PluginManagerOptions) {
|
||||||
this.app = options.app;
|
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({
|
this.app.db.registerRepositories({
|
||||||
PluginManagerRepository,
|
PluginManagerRepository,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.collection = this.app.db.collection(collectionOptions);
|
this.collection = this.app.db.collection(collectionOptions);
|
||||||
|
|
||||||
this.repository = this.collection.repository as PluginManagerRepository;
|
this._repository = this.collection.repository as PluginManagerRepository;
|
||||||
this.repository.setPluginManager(this);
|
this._repository.setPluginManager(this);
|
||||||
this.app.resourcer.define(resourceOptions);
|
this.app.resourcer.define(resourceOptions);
|
||||||
|
|
||||||
this.app.use(clientStaticMiddleware);
|
|
||||||
|
|
||||||
this.app.resourcer.use(async (ctx, next) => {
|
this.app.resourcer.use(async (ctx, next) => {
|
||||||
await next();
|
await next();
|
||||||
const { resourceName, actionName } = ctx.action;
|
const { resourceName, actionName } = ctx.action;
|
||||||
@ -72,297 +68,10 @@ export class PluginManager {
|
|||||||
name: 'pm',
|
name: 'pm',
|
||||||
actions: ['pm:*', 'applicationPlugins:list'],
|
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) {
|
get repository() {
|
||||||
for (const plugin of plugins || []) {
|
return this.app.db.getRepository('applicationPlugins') as PluginManagerRepository;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getPackageJson(packageName: string) {
|
static getPackageJson(packageName: string) {
|
||||||
@ -412,9 +121,354 @@ export class PluginManager {
|
|||||||
throw new Error(`No available packages found, ${name} plugin does not exist`);
|
throw new Error(`No available packages found, ${name} plugin does not exist`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static resolvePlugin(pluginName: string) {
|
static resolvePlugin(pluginName: string | typeof Plugin) {
|
||||||
const packageName = this.getPackageName(pluginName);
|
if (typeof pluginName === 'string') {
|
||||||
return requireModule(packageName);
|
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 { Application } from './application';
|
||||||
import { InstallOptions } from './plugin-manager';
|
import { InstallOptions } from './plugin-manager';
|
||||||
|
|
||||||
@ -22,24 +23,29 @@ export interface PluginOptions {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginType = typeof Plugin;
|
|
||||||
|
|
||||||
export abstract class Plugin<O = any> implements PluginInterface {
|
export abstract class Plugin<O = any> implements PluginInterface {
|
||||||
options: any;
|
options: any;
|
||||||
app: Application;
|
app: Application;
|
||||||
|
model: Model;
|
||||||
|
state: any = {};
|
||||||
|
|
||||||
constructor(app: Application, options?: any) {
|
constructor(app: Application, options?: any) {
|
||||||
this.setOptions(options);
|
|
||||||
|
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.setOptions(options);
|
this.setOptions(options);
|
||||||
this.afterAdd();
|
}
|
||||||
|
|
||||||
|
get log() {
|
||||||
|
return this.app.log;
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
return this.options.name as string;
|
return this.options.name as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get pm() {
|
||||||
|
return this.app.pm;
|
||||||
|
}
|
||||||
|
|
||||||
get db() {
|
get db() {
|
||||||
return this.app.db;
|
return this.app.db;
|
||||||
}
|
}
|
||||||
@ -52,6 +58,14 @@ export abstract class Plugin<O = any> implements PluginInterface {
|
|||||||
this.options.enabled = value;
|
this.options.enabled = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get installed() {
|
||||||
|
return this.options.installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
set installed(value) {
|
||||||
|
this.options.installed = value;
|
||||||
|
}
|
||||||
|
|
||||||
setOptions(options: any) {
|
setOptions(options: any) {
|
||||||
this.options = options || {};
|
this.options = options || {};
|
||||||
}
|
}
|
||||||
@ -72,9 +86,13 @@ export abstract class Plugin<O = any> implements PluginInterface {
|
|||||||
|
|
||||||
async afterEnable() {}
|
async afterEnable() {}
|
||||||
|
|
||||||
|
async beforeDisable() {}
|
||||||
|
|
||||||
async afterDisable() {}
|
async afterDisable() {}
|
||||||
|
|
||||||
async remove() {}
|
async beforeRemove() {}
|
||||||
|
|
||||||
|
async afterRemove() {}
|
||||||
|
|
||||||
async importCollections(collectionsPath: string) {
|
async importCollections(collectionsPath: string) {
|
||||||
await this.db.import({
|
await this.db.import({
|
||||||
|
@ -12,7 +12,8 @@
|
|||||||
"pg": "^8.7.3",
|
"pg": "^8.7.3",
|
||||||
"pg-hstore": "^2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
"sqlite3": "^5.0.8",
|
"sqlite3": "^5.0.8",
|
||||||
"supertest": "^6.1.6"
|
"supertest": "^6.1.6",
|
||||||
|
"ws": "^8.13.0"
|
||||||
},
|
},
|
||||||
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
|
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
|
import ws from 'ws';
|
||||||
|
|
||||||
export { mockDatabase } from '@nocobase/database';
|
export { mockDatabase } from '@nocobase/database';
|
||||||
export * from './mockServer';
|
export * from './mockServer';
|
||||||
|
export { default as supertest } from 'supertest';
|
||||||
|
|
||||||
export const pgOnly: () => jest.Describe = () => (process.env.DB_DIALECT == 'postgres' ? describe : describe.skip);
|
export const pgOnly: () => jest.Describe = () => (process.env.DB_DIALECT == 'postgres' ? describe : describe.skip);
|
||||||
|
|
||||||
@ -7,3 +10,54 @@ export function randomStr() {
|
|||||||
// create random string
|
// create random string
|
||||||
return Math.random().toString(36).substring(2);
|
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 { 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 jwt from 'jsonwebtoken';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import supertest, { SuperAgentTest } from 'supertest';
|
import supertest, { SuperAgentTest } from 'supertest';
|
||||||
@ -81,12 +81,19 @@ export class MockServer extends Application {
|
|||||||
await this.db.clean({ drop: true });
|
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 & {
|
agent(): SuperAgentTest & {
|
||||||
login: (user: any) => SuperAgentTest;
|
login: (user: any) => SuperAgentTest;
|
||||||
loginUsingId: (userId: number) => SuperAgentTest;
|
loginUsingId: (userId: number) => SuperAgentTest;
|
||||||
resource: (name: string, resourceOf?: any) => Resource;
|
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 prefix = this.resourcer.options.prefix;
|
||||||
const proxy = new Proxy(agent, {
|
const proxy = new Proxy(agent, {
|
||||||
get(target, method: string, receiver) {
|
get(target, method: string, receiver) {
|
||||||
@ -116,7 +123,8 @@ export class MockServer extends Application {
|
|||||||
{
|
{
|
||||||
get(target, method: string, receiver) {
|
get(target, method: string, receiver) {
|
||||||
return (params: ActionParams = {}) => {
|
return (params: ActionParams = {}) => {
|
||||||
let { filterByTk, values = {}, file, ...restParams } = params;
|
let { filterByTk } = params;
|
||||||
|
const { values = {}, file, ...restParams } = params;
|
||||||
if (params.associatedIndex) {
|
if (params.associatedIndex) {
|
||||||
resourceOf = params.associatedIndex;
|
resourceOf = params.associatedIndex;
|
||||||
}
|
}
|
||||||
@ -172,6 +180,9 @@ export function mockServer(options: ApplicationOptions = {}) {
|
|||||||
global.TextDecoder = require('util').TextDecoder;
|
global.TextDecoder = require('util').TextDecoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Gateway.getInstance().reset();
|
||||||
|
AppSupervisor.getInstance().reset();
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (!PluginManager.findPackagePatched) {
|
if (!PluginManager.findPackagePatched) {
|
||||||
PluginManager.getPackageJson = () => {
|
PluginManager.getPackageJson = () => {
|
||||||
|
@ -13,8 +13,6 @@ export async function prepareApp(): Promise<MockServer> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.loadAndInstall({ clean: true });
|
await app.loadAndInstall({ clean: true });
|
||||||
|
await app.start();
|
||||||
await app.db.sync();
|
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
@ -31,7 +31,7 @@ describe('actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await db.close();
|
await app.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('update profile with roles', async () => {
|
it('update profile with roles', async () => {
|
||||||
|
@ -342,23 +342,17 @@ export class PluginACL extends Plugin {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// sync database role data to acl
|
const writeRolesToACL = async (app, options) => {
|
||||||
this.app.on('afterLoad', async (app, options) => {
|
|
||||||
if (options?.method === 'install' || options?.method === 'upgrade') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const exists = await this.app.db.collectionExistsInDb('roles');
|
const exists = await this.app.db.collectionExistsInDb('roles');
|
||||||
if (exists) {
|
if (exists) {
|
||||||
|
this.log.info('write roles to ACL');
|
||||||
await this.writeRolesToACL();
|
await this.writeRolesToACL();
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
this.app.on('afterInstall', async (app, options) => {
|
// sync database role data to acl
|
||||||
const exists = await this.app.db.collectionExistsInDb('roles');
|
this.app.on('afterLoad', writeRolesToACL);
|
||||||
if (exists) {
|
this.app.on('afterInstall', writeRolesToACL);
|
||||||
await this.writeRolesToACL();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.app.on('afterInstallPlugin', async (plugin) => {
|
this.app.on('afterInstallPlugin', async (plugin) => {
|
||||||
if (plugin.getName() !== 'users') {
|
if (plugin.getName() !== 'users') {
|
||||||
|
@ -26,7 +26,7 @@ describe('actions', () => {
|
|||||||
await repo.destroy({
|
await repo.destroy({
|
||||||
truncate: true,
|
truncate: true,
|
||||||
});
|
});
|
||||||
await db.close();
|
await app.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
let user;
|
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: {
|
where: {
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
@ -8,9 +8,8 @@ describe('hook', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
api = mockServer();
|
api = mockServer();
|
||||||
await api.db.clean({ drop: true });
|
|
||||||
api.plugin(logPlugin, { name: 'audit-logs' });
|
api.plugin(logPlugin, { name: 'audit-logs' });
|
||||||
await api.load();
|
await api.loadAndInstall({ clean: true });
|
||||||
db = api.db;
|
db = api.db;
|
||||||
db.collection({
|
db.collection({
|
||||||
name: 'posts',
|
name: 'posts',
|
||||||
|
@ -27,7 +27,7 @@ describe('actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await db.close();
|
await app.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should list authenticator types', async () => {
|
it('should list authenticator types', async () => {
|
||||||
|
@ -19,7 +19,7 @@ describe('token-blacklist', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await db.close();
|
await app.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@ -66,7 +66,6 @@ describe('token-blacklist', () => {
|
|||||||
token: 'should not be deleted',
|
token: 'should not be deleted',
|
||||||
expiration: new Date('2100-01-01'),
|
expiration: new Date('2100-01-01'),
|
||||||
});
|
});
|
||||||
await tokenBlacklist.deleteByExpiration();
|
|
||||||
expect(await tokenBlacklist.has('should be deleted')).not.toBeTruthy();
|
expect(await tokenBlacklist.has('should be deleted')).not.toBeTruthy();
|
||||||
expect(await tokenBlacklist.has('should not be deleted')).toBeTruthy();
|
expect(await tokenBlacklist.has('should not be deleted')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
@ -9,36 +9,12 @@ export class TokenBlacklistService implements ITokenBlacklistService {
|
|||||||
|
|
||||||
constructor(protected plugin: AuthPlugin) {
|
constructor(protected plugin: AuthPlugin) {
|
||||||
this.repo = plugin.db.getRepository('tokenBlacklist');
|
this.repo = plugin.db.getRepository('tokenBlacklist');
|
||||||
this.cronJob = this.createCronJob();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get app() {
|
get app() {
|
||||||
return this.plugin.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) {
|
async has(token: string) {
|
||||||
return !!(await this.repo.findOne({
|
return !!(await this.repo.findOne({
|
||||||
filter: {
|
filter: {
|
||||||
@ -46,7 +22,9 @@ export class TokenBlacklistService implements ITokenBlacklistService {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async add(values) {
|
async add(values) {
|
||||||
|
await this.deleteExpiredTokens();
|
||||||
return this.repo.model.findOrCreate({
|
return this.repo.model.findOrCreate({
|
||||||
defaults: values,
|
defaults: values,
|
||||||
where: {
|
where: {
|
||||||
@ -54,7 +32,8 @@ export class TokenBlacklistService implements ITokenBlacklistService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async deleteByExpiration() {
|
|
||||||
|
async deleteExpiredTokens() {
|
||||||
return this.repo.destroy({
|
return this.repo.destroy({
|
||||||
filter: {
|
filter: {
|
||||||
expiration: {
|
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