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:
ChengLei Shao 2023-08-24 17:47:45 +08:00 committed by GitHub
parent 0f9d946f09
commit 39ee4b8873
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
149 changed files with 5787 additions and 2049 deletions

View File

@ -51,7 +51,7 @@ INIT_ROOT_USERNAME=nocobase
DEFAULT_STORAGE_TYPE=local
# LOCAL STORAGE
LOCAL_STORAGE_BASE_URL=
LOCAL_STORAGE_BASE_URL=/storage/uploads
LOCAL_STORAGE_DEST=storage/uploads
# ALI OSS STORAGE

View File

@ -44,7 +44,7 @@ jobs:
DB_DIALECT: sqlite
DB_STORAGE: /tmp/db.sqlite
DB_UNDERSCORED: ${{ matrix.underscored }}
timeout-minutes: 35
timeout-minutes: 40
postgres-test:
strategy:
@ -92,7 +92,7 @@ jobs:
DB_UNDERSCORED: ${{ matrix.underscored }}
DB_SCHEMA: ${{ matrix.schema }}
COLLECTION_MANAGER_SCHEMA: ${{ matrix.collection_schema }}
timeout-minutes: 35
timeout-minutes: 40
mysql-test:
strategy:
@ -128,4 +128,4 @@ jobs:
DB_PASSWORD: password
DB_DATABASE: nocobase
DB_UNDERSCORED: ${{ matrix.underscored }}
timeout-minutes: 35
timeout-minutes: 40

View File

@ -40,7 +40,6 @@ RUN cd /app \
FROM node:16.20-bullseye-slim
RUN apt-get update && apt-get install -y nginx
RUN rm -rf /etc/nginx/sites-enabled/default
COPY ./docker/nocobase/nocobase.conf /etc/nginx/sites-enabled/nocobase.conf
COPY --from=builder /app/nocobase.tar.gz /app/nocobase.tar.gz

View File

@ -8,6 +8,7 @@ NocoBase is in early stage of development and is subject to frequent changes, pl
## Recent major updates
- [v0.13: New application status flow - 2023/08/24](https://docs.nocobase.com/welcome/release/v13-changelog)
- [v0.12: New plugin build tool - 2023/08/01](https://docs.nocobase.com/welcome/release/v12-changelog)
- [v0.11: New client application, plugin and router - 2023/07/08](http://docs.nocobase.com/welcome/release/v11-changelog)
- [v0.10: Update instructions - 2023/06/23](http://docs.nocobase.com/welcome/release/v10-changelog)

View File

@ -10,6 +10,8 @@ NocoBase v0.10 has been released, see [v0.10: update instructions](http://docs.n
## Recent major updates
- [v0.13: New application status flow - 2023/08/24](https://docs.nocobase.com/welcome/release/v13-changelog)
- [v0.12: New plugin build tool - 2023/08/01](https://docs.nocobase.com/welcome/release/v12-changelog)
- [v0.11: New client application, plugin and router - 2023/07/08](http://docs.nocobase.com/welcome/release/v11-changelog)
- [v0.10: Update instructions - 2023/06/23](http://docs.nocobase.com/welcome/release/v10-changelog)

View File

@ -8,6 +8,7 @@ NocoBase 正处在早期开发阶段,可能变动频繁,请谨慎用于生
## 最近重要更新
- [v0.13: 全新的应用状态流转 - 2023/08/24](https://docs-cn.nocobase.com/welcome/release/v13-changelog)
- [v0.12: 全新的插件构建工具 - 2023/08/01](https://docs-cn.nocobase.com/welcome/release/v12-changelog)
- [v0.11: 全新的客户端 Application、Plugin 和 Router - 2023/07/08](https://docs-cn.nocobase.com/welcome/release/v11-changelog)
- [v0.10: Update instructions - 2023/06/23](https://docs-cn.nocobase.com/welcome/release/v10-changelog)

View File

@ -1,3 +1,17 @@
log_format apm '"$time_local" client=$remote_addr '
'method=$request_method request="$request" '
'request_length=$request_length '
'status=$status bytes_sent=$bytes_sent '
'body_bytes_sent=$body_bytes_sent '
'referer=$http_referer '
'user_agent="$http_user_agent" '
'upstream_addr=$upstream_addr '
'upstream_status=$upstream_status '
'request_time=$request_time '
'upstream_response_time=$upstream_response_time '
'upstream_connect_time=$upstream_connect_time '
'upstream_header_time=$upstream_header_time';
server {
listen 80;
server_name _;
@ -5,6 +19,8 @@ server {
index index.html;
client_max_body_size 20M;
access_log /var/log/nginx/nocobase.log apm;
location /storage/uploads/ {
alias /app/nocobase/storage/uploads/;
autoindex off;

View File

@ -1,9 +1,24 @@
log_format apm '"$time_local" client=$remote_addr '
'method=$request_method request="$request" '
'request_length=$request_length '
'status=$status bytes_sent=$bytes_sent '
'body_bytes_sent=$body_bytes_sent '
'referer=$http_referer '
'user_agent="$http_user_agent" '
'upstream_addr=$upstream_addr '
'upstream_status=$upstream_status '
'request_time=$request_time '
'upstream_response_time=$upstream_response_time '
'upstream_connect_time=$upstream_connect_time '
'upstream_header_time=$upstream_header_time';
server {
listen 80;
server_name _;
root /app/nocobase/node_modules/@nocobase/app/dist/client;
index index.html;
client_max_body_size 20M;
access_log /var/log/nginx/nocobase.log apm;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
@ -50,4 +65,12 @@ server {
proxy_read_timeout 600;
send_timeout 600;
}
location /ws {
proxy_pass http://127.0.0.1:13000/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
}

View File

@ -84,6 +84,7 @@ const sidebar = {
},
// '/welcome/release/index',
// '/welcome/release/v08-changelog',
'/welcome/release/v13-changelog',
'/welcome/release/v12-changelog',
'/welcome/release/v11-changelog',
'/welcome/release/v10-changelog',

View File

@ -1,8 +1,14 @@
# Upgrading for `create-nocobase-app`
## Minor version upgrade
<Alert>
Just execute the `nocobase upgrade` upgrade command
After v0.12, apps installed via `create-nocobase-app` no longer have a `packages/app` directory, and code customized in `packages/app` needs to be moved to the custom plugin.
</Alert>
## Upgrading
After v0.12, upgrading the application can be done by running the `yarn nocobase upgrade` command.
```bash
# Switch to the corresponding directory
@ -13,63 +19,4 @@ yarn nocobase upgrade
yarn dev
```
## Major upgrade
You can also use this upgrade method if a minor upgrade fails.
### 1. Create a new NocoBase project
```bash
## SQLite
yarn create nocobase-app my-nocobase-app -d sqlite
# MySQL
yarn create nocobase-app my-nocobase-app -d mysql
# PostgreSQL
yarn create nocobase-app my-nocobase-app -d postgres
```
### 2. Switching directories
```bash
cd my-nocobase-app
```
### 3. Install dependencies
📢 This next step may take more than ten minutes due to network environment, system configuration and other factors.
```bash
yarn install
```
### 4. Modify the .env configuration
Refer to the old version of .env to modify, the database information needs to be configured correctly. The SQLite database also needs to be copied to the `. /storage/db/` directory.
### 5. Old code migration (not required)
Business code refer to the new version of plug-in development tutorial and API reference for modification.
### 6. Execute the upgrade command
The code is already the latest version, so you need to skip the code update `--skip-code-update` when upgrading.
```bash
yarn nocobase upgrade --skip-code-update
```
### 7. Start NocoBase
development environment
```bash
yarn dev
```
Production environment
```bash
yarn start # Not supported on win platforms yet
```
Note: For production environment, if the code has been modified, you need to execute ``yarn build`` and restart NocoBase.
If there are problems with upgrading, you can also [recreate new app](/welcome/getting-started/installation/create-nocobase-app) and refer to the old version of .env to change the environment variables. The database information needs to be configured correctly. When using a SQLite database, you need to copy the database files to the `./storage/db/` directory. Finally, run `yarn nocobase upgrade` to upgrade.

View File

@ -12,16 +12,12 @@ cd my-nocobase-app
git pull
```
## 3. 删除旧依赖文件(非必须)
v0.10 进行了依赖的重大升级,如果 v0.9 升级 v0.10,需要删掉以下目录之后再升级
## 3. Delete cache and dependencies (optional)
```bash
# Remove .umi cache
yarn rimraf -rf "./**/{.umi,.umi-production}"
# Delete compiled files
yarn rimraf -rf "./packages/*/*/{lib,esm,es,dist,node_modules}"
# Remove dependencies
# delete nocobase cache
yarn nocobase clean
# delete dependencies
yarn rimraf -rf node_modules
```

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

View File

@ -1,12 +1,12 @@
# `create-nocobase-app` 安装的升级
<Alert>
通过 create-nocobase-app 安装的应用不再有 packages/app 目录了,在 packages/app 里自定义的代码,需要移至自定义插件中。
v0.12 之后的版本,通过 create-nocobase-app 安装的应用不再有 packages/app 目录了,在 packages/app 里自定义的代码,需要移至自定义插件中。
</Alert>
## 版本升级
## 版本升级
执行 `nocobase upgrade` 升级命令即可
v0.12 之后的版本,应用的升级直接执行 `yarn nocobase upgrade` 升级命令即可
```bash
# 切换到对应的目录
@ -17,63 +17,4 @@ yarn nocobase upgrade
yarn dev
```
## 大版本升级
如果小版本升级失效,也可以采用此升级办法。
### 1. 创建新的 NocoBase 项目
```bash
# SQLite
yarn create nocobase-app my-nocobase-app -d sqlite
# MySQL
yarn create nocobase-app my-nocobase-app -d mysql
# PostgreSQL
yarn create nocobase-app my-nocobase-app -d postgres
```
### 2. 切换目录
```bash
cd my-nocobase-app
```
### 3. 安装依赖
📢 由于网络环境、系统配置等因素影响,接下来这一步骤可能需要十几分钟时间。
```bash
yarn install
```
### 4. 修改 .env 配置
参考旧版本的 .env 修改数据库信息需要配置正确。SQLite 数据库也需要将数据库文件复制到 `./storage/db/` 目录。
### 5. 旧代码迁移(非必须)
业务代码参考新版插件开发教程和 API 参考进行修改。
### 6. 执行升级命令
代码已经是最新版了,所以 upgrade 时需要跳过代码更新 `--skip-code-update`
```bash
yarn nocobase upgrade --skip-code-update
```
### 7. 启动 NocoBase
开发环境
```bash
yarn dev
```
生产环境
```bash
yarn start
```
注:生产环境,如果代码有修改,需要执行 `yarn build`,再重新启动 NocoBase。
如果升级存在问题,也可以[重新创建新应用](/welcome/getting-started/installation/create-nocobase-app),并参考旧版本的 .env 修改环境变量。数据库信息需要配置正确,使用 SQLite 数据库时,需要将数据库文件复制到 `./storage/db/` 目录。最后再执行 `yarn nocobase upgrade` 进行升级。

View File

@ -12,16 +12,14 @@ cd my-nocobase-app
git pull
```
## 3. 删除旧依赖文件(非必须)
## 3. 删除缓存和旧依赖(非必须)
v0.10 进行了依赖的重大升级,如果 v0.9 升级 v0.10,需要删掉以下目录之后再升级
如果正常的升级流程失败,可以尝试清空缓存和依赖之后重新下载
```bash
# 删除 .umi 相关缓存
yarn rimraf -rf "./**/{.umi,.umi-production}"
# 删除编译文件
yarn rimraf -rf "./packages/*/*/{lib,esm,es,dist,node_modules}"
# 删除全部依赖
# 删除 nocobase 缓存
yarn nocobase clean
# 删除依赖
yarn rimraf -rf node_modules
```

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

View File

@ -9,8 +9,9 @@ import { MockServer, mockServer } from '@nocobase/test';
describe('app test', () => {
let app: MockServer;
beforeEach(() => {
beforeEach(async () => {
app = mockServer();
await app.start();
});
test('test1', async () => {

View File

@ -15,6 +15,7 @@
"nocobase": "nocobase",
"pm": "nocobase pm",
"dev": "nocobase dev",
"dev-server": "nocobase dev --server",
"start": "nocobase start",
"build": "nocobase build",
"test": "nocobase test",

View File

@ -7,6 +7,7 @@ export const app = new Application({
baseURL: process.env.API_BASE_URL,
},
plugins: [NocoBaseClientPresetPlugin],
ws: true,
devDynamicImport,
});

View File

@ -1,10 +1,6 @@
import { Application } from '@nocobase/server';
import { Gateway } from '@nocobase/server';
import config from './config';
const app = new Application(config);
if (require.main === module) {
app.runAsCLI();
}
export default app;
Gateway.getInstance().run({
mainAppOptions: config,
});

View File

@ -68,7 +68,7 @@ describe('auth-manager', () => {
});
afterEach(async () => {
await db.close();
await app.destroy();
});
describe('blacklist', () => {

View File

@ -17,6 +17,7 @@
"execa": "^5.1.1",
"fast-glob": "^3.2.12",
"fs-extra": "^11.1.1",
"newrelic": "^10.6.2",
"pm2": "^5.2.0",
"portfinder": "^1.0.28",
"serve": "^13.0.2",

View File

@ -54,8 +54,6 @@ module.exports = (cli) => {
});
}
await runAppCommand('install', ['--silent']);
if (server || !client) {
console.log('starting server', serverPort);
@ -99,6 +97,7 @@ module.exports = (cli) => {
env: {
PORT: clientPort,
APP_ROOT: `${APP_PACKAGE_ROOT}/client`,
WEBSOCKET_URL: process.env.WEBSOCKET_URL || `ws://localhost:${serverPort}/ws`,
PROXY_TARGET_URL:
process.env.PROXY_TARGET_URL || (serverPort ? `http://127.0.0.1:${serverPort}` : undefined),
},

View File

@ -12,11 +12,10 @@ module.exports = (cli) => {
.option('-h, --help')
.option('--ts-node-dev')
.action((options) => {
const { tsNodeDev } = options;
if (isDev()) {
promptForTs();
run(tsNodeDev ? 'ts-node-dev' : 'ts-node', [
'-P',
run('tsx', [
'--tsconfig',
'./tsconfig.server.json',
'-r',
'tsconfig-paths/register',

View File

@ -41,16 +41,6 @@ module.exports = (cli) => {
return;
}
await postCheck(opts);
const restartMark = resolve(process.cwd(), 'storage', 'restart');
if (!existsSync(restartMark)) {
if (opts.quickstart) {
await run('node', [`${APP_PACKAGE_ROOT}/lib/index.js`, 'install', '--ignore-installed']);
await run('node', [`${APP_PACKAGE_ROOT}/lib/index.js`, 'upgrade']);
}
if (opts.dbSync) {
await run('node', [`${APP_PACKAGE_ROOT}/lib/index.js`, 'db:sync']);
}
}
if (opts.daemon) {
run('pm2', ['start', `${APP_PACKAGE_ROOT}/lib/index.js`, '--', ...process.argv.slice(2)]);
} else {

View File

@ -1,9 +1,9 @@
import { Field } from '@formily/core';
import { Schema, useField, useFieldSchema } from '@formily/react';
import { Spin } from 'antd';
import React, { createContext, useContext, useEffect } from 'react';
import { Navigate } from 'react-router-dom';
import { useAPIClient, useRequest } from '../api-client';
import { useAppSpin } from '../application/hooks/useAppSpin';
import { useBlockRequestContext } from '../block-provider/BlockProvider';
import { useCollection } from '../collection-manager';
import { useResourceActionContext } from '../collection-manager/ResourceActionProvider';
@ -33,6 +33,7 @@ const getRouteUrl = (props) => {
export const ACLRolesCheckProvider = (props) => {
const route = getRouteUrl(props.children.props);
const { setDesignable } = useDesignable();
const { render } = useAppSpin();
const api = useAPIClient();
const result = useRequest<{
data: {
@ -60,7 +61,7 @@ export const ACLRolesCheckProvider = (props) => {
},
);
if (result.loading) {
return <Spin />;
return render();
}
if (result.error) {
return <Navigate replace to={'/signin'} />;

View File

@ -2,6 +2,7 @@ import { APIClient as APIClientSDK } from '@nocobase/sdk';
import { Result } from 'ahooks/es/useRequest/src/types';
import { notification } from 'antd';
import React from 'react';
import { Application } from '../application';
const handleErrorMessage = (error, notification) => {
const reader = new FileReader();
@ -9,7 +10,7 @@ const handleErrorMessage = (error, notification) => {
reader.onload = function () {
notification.error({
message: JSON.parse(reader.result as string).errors?.map?.((error: any) => {
return React.createElement('div', { children: error.message });
return React.createElement('div', {}, error.message);
}),
});
};
@ -19,6 +20,7 @@ const errorCache = new Map();
export class APIClient extends APIClientSDK {
services: Record<string, Result<any, any>> = {};
silence = false;
app: Application;
/** 该值会在 AntdAppProvider 中被重新赋值 */
notification: any = notification;
@ -56,6 +58,16 @@ export class APIClient extends APIClientSDK {
if (errorCache.size > 10) {
errorCache.clear();
}
const maintaining = !!error?.response?.data?.error?.maintaining;
if (this.app.maintaining !== maintaining) {
this.app.maintaining = maintaining;
}
if (this.app.maintaining) {
this.app.error = error?.response?.data?.error;
return;
} else if (this.app.error) {
this.app.error = null;
}
let errs = error?.response?.data?.errors || [{ message: 'Server error' }];
errs = errs.filter((error) => {
const lastTime = errorCache.get(error.message);

View File

@ -1,6 +1,6 @@
import { Spin } from 'antd';
import React, { createContext, useContext } from 'react';
import { useRequest } from '../api-client';
import { useAppSpin } from '../application/hooks/useAppSpin';
export const CurrentAppInfoContext = createContext(null);
@ -16,11 +16,12 @@ export const useCurrentAppInfo = () => {
}>(CurrentAppInfoContext);
};
export const CurrentAppInfoProvider = (props) => {
const { render } = useAppSpin();
const result = useRequest({
url: 'app:getInfo',
});
if (result.loading) {
return <Spin />;
return render();
}
return <CurrentAppInfoContext.Provider value={result.data}>{props.children}</CurrentAppInfoContext.Provider>;
};

View File

@ -1,3 +1,4 @@
import { define, observable } from '@formily/reactive';
import { APIClientOptions } from '@nocobase/sdk';
import { i18n as i18next } from 'i18next';
import get from 'lodash/get';
@ -12,11 +13,12 @@ import { i18n } from '../i18n';
import type { Plugin } from './Plugin';
import { PluginManager, PluginType } from './PluginManager';
import { ComponentTypeAndString, RouterManager, RouterOptions } from './RouterManager';
import { WebSocketClient, WebSocketClientOptions } from './WebSocketClient';
import { AppComponent, BlankComponent, defaultAppComponents } from './components';
import { compose, normalizeContainer } from './utils';
import { defineGlobalDeps } from './utils/globalDeps';
import { getRequireJs } from './utils/requirejs';
import type { RequireJS } from './utils/requirejs';
import { getRequireJs } from './utils/requirejs';
declare global {
interface Window {
@ -28,6 +30,7 @@ export type DevDynamicImport = (packageName: string) => Promise<{ default: typeo
export type ComponentAndProps<T = any> = [ComponentType, T];
export interface ApplicationOptions {
apiClient?: APIClientOptions;
ws?: WebSocketClientOptions | boolean;
i18n?: i18next;
providers?: (ComponentType | ComponentAndProps)[];
plugins?: PluginType[];
@ -42,18 +45,30 @@ export class Application {
public router: RouterManager;
public scopes: Record<string, any> = {};
public i18n: i18next;
public ws: WebSocketClient;
public apiClient: APIClient;
public components: Record<string, ComponentType> = { ...defaultAppComponents };
public pm: PluginManager;
public devDynamicImport: DevDynamicImport;
public requirejs: RequireJS;
loading = true;
maintained = false;
maintaining = false;
error = null;
constructor(protected options: ApplicationOptions = {}) {
this.initRequireJs();
define(this, {
maintained: observable.ref,
loading: observable.ref,
maintaining: observable.ref,
error: observable.ref,
});
this.devDynamicImport = options.devDynamicImport;
this.scopes = merge(this.scopes, options.scopes);
this.components = merge(this.components, options.components);
this.apiClient = new APIClient(options.apiClient);
this.apiClient.app = this;
this.i18n = options.i18n || i18n;
this.router = new RouterManager({
...options.router,
@ -63,6 +78,7 @@ export class Application {
this.addDefaultProviders();
this.addReactRouterComponents();
this.addProviders(options.providers || []);
this.ws = new WebSocketClient(options.ws);
}
private initRequireJs() {
@ -109,7 +125,38 @@ export class Application {
}
async load() {
return this.pm.load();
this.ws.on('message', (event) => {
const data = JSON.parse(event.data);
console.log(data.payload);
const maintaining = data.type === 'maintaining' && data.payload.code !== 'APP_RUNNING';
if (maintaining) {
this.maintaining = true;
this.error = data.payload;
} else if (this.maintaining) {
// && !this.maintained
window.location.reload();
return;
} else {
this.maintaining = false;
this.maintained = true;
this.error = null;
}
});
this.ws.on('serverDown', () => {
this.maintaining = true;
});
this.ws.connect();
try {
this.loading = true;
await this.pm.load();
} catch (error) {
this.error = {
code: 'LOAD_ERROR',
message: error.message,
};
console.error(error);
}
this.loading = false;
}
getComponent<T = any>(Component: ComponentTypeAndString<T>, isShowError = true): ComponentType<T> | undefined {

View File

@ -41,7 +41,7 @@ export class PluginManager {
private async initRemotePlugins() {
try {
const res = await this.app.apiClient.request({ url: 'app:getPlugins' });
const pluginList: PluginData[] = res.data?.data || [];
const pluginList: PluginData[] = res?.data?.data || [];
const plugins = await getPlugins({
requirejs: this.app.requirejs,
pluginData: pluginList,

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

View File

@ -1,25 +1,33 @@
import React, { FC, useCallback } from 'react';
import { observer } from '@formily/reactive-react';
import React, { FC, useCallback, useEffect } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import type { Application } from '../Application';
import { ApplicationContext } from '../context';
import { useAppPluginLoad } from '../hooks';
export interface AppComponentProps {
app: Application;
}
export const AppComponent: FC<AppComponentProps> = (props) => {
export const AppComponent: FC<AppComponentProps> = observer((props) => {
const { app } = props;
const { loading, error } = useAppPluginLoad(app);
const handleErrors = useCallback((error: Error, info: { componentStack: string }) => {
console.error(error, info);
}, []);
if (loading) return app.renderComponent('AppSpin', { app });
if (error) return app.renderComponent('AppError', { app, error });
useEffect(() => {
app.load();
}, [app]);
if (app.loading) return app.renderComponent('AppSpin', { app });
if (!app.maintained && app.maintaining) return app.renderComponent('AppMaintaining', { app });
if (app.error?.code === 'LOAD_ERROR') return app.renderComponent('AppError', { app });
return (
<ErrorBoundary FallbackComponent={app.getComponent<FallbackProps>('ErrorFallback')} onError={handleErrors}>
<ApplicationContext.Provider value={app}>{app.renderComponent('AppMain')}</ApplicationContext.Provider>
<ApplicationContext.Provider value={app}>
{app.maintained && app.maintaining && app.renderComponent('AppMaintainingDialog', { app })}
{app.renderComponent('AppMain')}
</ApplicationContext.Provider>
</ErrorBoundary>
);
};
});

View File

@ -1,4 +1,4 @@
import { createContext } from 'react';
import type { Application } from './Application';
import { Application } from './Application';
export const ApplicationContext = createContext<Application>(null);

View File

@ -5,6 +5,9 @@ export function useAppPluginLoad(app: Application) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
app.ws.on('message', (event) => {
console.log(event.data);
});
async function run() {
try {
await app.load();

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

View File

@ -1,7 +1,7 @@
import { Spin } from 'antd';
import React, { createContext, useContext, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useAPIClient, useRequest } from '../api-client';
import { useAppSpin } from '../application/hooks/useAppSpin';
export interface CollectionHistoryContextValue {
historyCollections: any[];
@ -41,6 +41,7 @@ export const CollectionHistoryProvider: React.FC = (props) => {
const isAdminPage = location.pathname.startsWith('/admin');
const token = api.auth.getToken() || '';
const { render } = useAppSpin();
useEffect(() => {
if (isAdminPage && token) {
@ -56,7 +57,7 @@ export const CollectionHistoryProvider: React.FC = (props) => {
};
if (service.loading) {
return <Spin />;
return render();
}
return (

View File

@ -1,8 +1,8 @@
import { Spin } from 'antd';
import { keyBy } from 'lodash';
import React, { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../api-client';
import { useAppSpin } from '../application/hooks/useAppSpin';
import { templateOptions } from '../collection-manager/Configuration/templates';
import { useCollectionHistory } from './CollectionHistoryProvider';
import { CollectionManagerSchemaComponentProvider } from './CollectionManagerSchemaComponentProvider';
@ -36,6 +36,7 @@ export const RemoteCollectionManagerProvider = (props: any) => {
const api = useAPIClient();
const [contentLoading, setContentLoading] = useState(false);
const { refreshCH } = useCollectionHistory();
const { render } = useAppSpin();
const options = {
resource: 'collections',
action: 'list',
@ -63,7 +64,7 @@ export const RemoteCollectionManagerProvider = (props: any) => {
}>(coptions);
if (service.loading) {
return <Spin />;
return render();
}
const refreshCM = async (opts) => {
if (opts?.reload) {

View File

@ -769,7 +769,7 @@ export default {
'Reload application': '重载应用',
'The application is reloading, please do not close the page.': '应用正在重新加载,请勿关闭页面。',
'Application reloading': '应用重新加载中',
'Reboot application': '重启应用',
'Restart application': '重启应用',
"Allows to clear cache, reboot application": "允许清除缓存,重启应用",
'The will interrupt service, it may take a few seconds to restart. Are you sure to continue?': '重启将会中断当前服务,这个过程可能需要一点时间,确定要继续吗?',
'Reboot': '重启',

View File

@ -1,5 +1,7 @@
import { LoadingOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { Button, Result, Spin } from 'antd';
import { observer } from '@formily/reactive-react';
import { Button, Modal, Result, Spin } from 'antd';
import React, { FC } from 'react';
import { Navigate } from 'react-router-dom';
import { ACLPlugin } from '../acl';
@ -21,9 +23,13 @@ import { SystemSettingsPlugin } from '../system-settings';
import { CurrentUserProvider, CurrentUserSettingsMenuProvider } from '../user';
import { LocalePlugin } from './plugins/LocalePlugin';
const AppSpin = Spin;
const AppSpin = () => {
return (
<Spin style={{ position: 'fixed', top: '50%', left: '50%', fontSize: 72, transform: 'translate(-50%, -50%)' }} />
);
};
const AppError: FC<{ app: Application; error: Error }> = ({ app, error }) => (
const AppError: FC<{ app: Application }> = observer(({ app }) => (
<div>
<Result
className={css`
@ -34,7 +40,7 @@ const AppError: FC<{ app: Application; error: Error }> = ({ app, error }) => (
`}
status="error"
title={app.i18n.t('Failed to load plugin')}
subTitle={app.i18n.t(error?.message)}
subTitle={app.i18n.t(app.error?.message)}
extra={[
<Button type="primary" key="try" onClick={() => window.location.reload()}>
{app.i18n.t('Try again')}
@ -42,13 +48,142 @@ const AppError: FC<{ app: Application; error: Error }> = ({ app, error }) => (
]}
/>
</div>
);
));
const getProps = (app: Application) => {
if (app.ws.serverDown) {
return {
status: 'error',
title: 'App error',
subTitle: 'The server is down',
};
}
if (!app.error) {
return {};
}
if (app.error.code === 'APP_NOT_FOUND') {
return {
status: 'warning',
title: 'App not found',
subTitle: app.error?.message,
};
}
if (app.error.code === 'APP_INITIALIZING') {
return {
status: 'info',
icon: <LoadingOutlined />,
title: 'App initializing',
subTitle: app.error?.message,
};
}
if (app.error.code === 'APP_INITIALIZED') {
return {
status: 'warning',
title: 'App initialized',
subTitle: app.error?.message,
};
}
if (app.error.code === 'APP_ERROR') {
return {
status: 'error',
title: 'App error',
subTitle: app.error?.message,
};
}
if (app.error.code === 'APP_NOT_INSTALLED_ERROR') {
return {
status: 'warning',
title: 'App not installed',
subTitle: app.error?.message,
};
}
if (app.error.code === 'APP_STOPPED') {
return {
status: 'warning',
title: 'App stopped',
subTitle: app.error?.message,
};
}
if (app.error.code === 'APP_COMMANDING') {
const props = {
status: 'info',
icon: <LoadingOutlined />,
title: app.error?.command?.name,
subTitle: app.error?.message,
};
const commands = {
start: {
title: 'App starting',
},
restart: {
title: 'App restarting',
},
install: {
title: 'App installing',
},
upgrade: {
title: 'App upgrading',
},
'pm.enable': {
title: 'Enabling plugin',
},
'pm.disable': {
title: 'Disabling plugin',
},
};
return { ...props, ...commands[app.error?.command?.name] };
}
};
const AppMaintaining: FC<{ app: Application; error: Error }> = observer(({ app }) => {
const { icon, status, title, subTitle } = getProps(app);
return (
<div>
<Result
className={css`
top: 50%;
position: absolute;
width: 100%;
transform: translate(0, -50%);
`}
icon={icon}
status={status}
title={app.i18n.t(title)}
subTitle={app.i18n.t(subTitle)}
// extra={[
// <Button type="primary" key="try" onClick={() => window.location.reload()}>
// {app.i18n.t('Try again')}
// </Button>,
// ]}
/>
</div>
);
});
const AppMaintainingDialog: FC<{ app: Application; error: Error }> = observer(({ app }) => {
const { icon, status, title, subTitle } = getProps(app);
return (
<Modal open={true} footer={null} closable={false}>
<Result icon={icon} status={status} title={app.i18n.t(title)} subTitle={app.i18n.t(subTitle)} />
</Modal>
);
});
export class NocoBaseBuildInPlugin extends Plugin {
async afterAdd() {
this.app.addComponents({
AppSpin,
AppError,
AppMaintaining,
AppMaintainingDialog,
});
await this.addPlugins();
}

View File

@ -10,12 +10,13 @@ export class LocalePlugin extends Plugin {
const api = this.app.apiClient;
const locale = api.auth.locale;
try {
const { data } = await api.request({
const res = await api.request({
url: 'app:getLang',
params: {
locale,
},
});
const data = res?.data;
this.locales = data?.data || {};
this.app.use(ConfigProvider, { locale: this.locales.antd, popupMatchSelectWidth: false });
if (data?.data?.lang && !locale) {

View File

@ -247,7 +247,7 @@ export const PluginCard = (props: { data: IPluginData }) => {
url: `pm:remove/${name}`,
});
message.success(t('插件删除成功'));
window.location.reload();
// window.location.reload();
}}
onCancel={(e) => e.stopPropagation()}
okText={t('Yes')}
@ -260,19 +260,19 @@ export const PluginCard = (props: { data: IPluginData }) => {
size={'small'}
onChange={async (checked, e) => {
e.stopPropagation();
modal.warning({
title: checked ? t('Plugin starting') : t('Plugin stopping'),
content: t('The application is reloading, please do not close the page.'),
okButtonProps: {
style: {
display: 'none',
},
},
});
// modal.warning({
// title: checked ? t('Plugin starting') : t('Plugin stopping'),
// content: t('The application is reloading, please do not close the page.'),
// okButtonProps: {
// style: {
// display: 'none',
// },
// },
// });
await api.request({
url: `pm:${checked ? 'enable' : 'disable'}/${name}`,
});
window.location.reload();
// window.location.reload();
// message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
}}
defaultChecked={enabled}

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { Layout, Spin } from 'antd';
import { Layout } from 'antd';
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Outlet, useMatch, useNavigate, useParams } from 'react-router-dom';
import {
@ -22,6 +22,7 @@ import {
useToken,
} from '../../../';
import { Plugin } from '../../../application/Plugin';
import { useAppSpin } from '../../../application/hooks/useAppSpin';
import { useCollectionManager } from '../../../collection-manager';
const filterByACL = (schema, options) => {
@ -74,7 +75,7 @@ const MenuEditor = (props) => {
setCurrent(schema);
navigate(`/admin/${schema['x-uid']}`);
};
const { render } = useAppSpin();
const adminSchemaUid = useAdminSchemaUid();
const { data, loading } = useRequest<{
data: any;
@ -140,7 +141,7 @@ const MenuEditor = (props) => {
return s;
}, [data?.data]);
if (loading) {
return <Spin />;
return render();
}
return (
<SchemaIdContext.Provider value={defaultSelectedUid}>
@ -155,6 +156,7 @@ export const InternalAdminLayout = (props: any) => {
const { service } = useCollectionManager();
const params = useParams<any>();
const { token } = useToken();
const { render } = useAppSpin();
return (
<Layout>
@ -299,7 +301,7 @@ export const InternalAdminLayout = (props: any) => {
pointer-events: none;
`}
></header>
{service.contentLoading ? <Spin /> : <Outlet />}
{service.contentLoading ? render() : <Outlet />}
</Layout.Content>
</Layout>
);

View File

@ -2,13 +2,14 @@ import { PlusOutlined } from '@ant-design/icons';
import { PageHeader as AntdPageHeader } from '@ant-design/pro-layout';
import { FormLayout } from '@formily/antd-v5';
import { Schema, SchemaOptionsContext, useFieldSchema } from '@formily/react';
import { Button, Spin, Tabs } from 'antd';
import { Button, Tabs } from 'antd';
import classNames from 'classnames';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { FormDialog } from '..';
import { useAppSpin } from '../../../application/hooks/useAppSpin';
import { useDocumentTitle } from '../../../document-title';
import { FilterBlockProvider } from '../../../filter-provider/FilterProvider';
import { useGlobalTheme } from '../../../global-theme';
@ -194,9 +195,10 @@ function PageContent(
props: any,
): React.ReactNode {
const { token } = useToken();
const { render } = useAppSpin();
if (loading) {
return <Spin />;
return render();
}
return !disablePageHeader && enablePageTabs ? (

View File

@ -1,10 +1,10 @@
import { ISchema, useFieldSchema } from '@formily/react';
import { uid } from '@formily/shared';
import { Spin } from 'antd';
import { cloneDeep } from 'lodash';
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
import React, { ReactNode, createContext, useContext, useMemo } from 'react';
import { useAPIClient, useRequest } from '../api-client';
import { Plugin } from '../application/Plugin';
import { useAppSpin } from '../application/hooks/useAppSpin';
import { useCollectionManager } from '../collection-manager';
import { BlockTemplate } from './BlockTemplate';
@ -116,6 +116,7 @@ export const useSchemaTemplateManager = () => {
export const RemoteSchemaTemplateManagerProvider: React.FC<{ children?: ReactNode }> = (props) => {
const api = useAPIClient();
const { render } = useAppSpin();
const options = {
resource: 'uiSchemaTemplates',
action: 'list',
@ -128,7 +129,7 @@ export const RemoteSchemaTemplateManagerProvider: React.FC<{ children?: ReactNod
data: any[];
}>(options);
if (service.loading) {
return <Spin />;
return render();
}
return (
<SchemaTemplateManagerProvider

View File

@ -1,7 +1,7 @@
import { Result } from 'ahooks/es/useRequest/src/types';
import { Spin } from 'antd';
import React, { createContext, ReactNode, useContext } from 'react';
import { useRequest } from '..';
import { useRequest } from '../api-client';
import { useAppSpin } from '../application/hooks/useAppSpin';
export const SystemSettingsContext = createContext<Result<any, any>>(null);
@ -10,11 +10,12 @@ export const useSystemSettings = () => {
};
export const SystemSettingsProvider: React.FC<{ children?: ReactNode }> = (props) => {
const { render } = useAppSpin();
const result = useRequest({
url: 'systemSettings:get/1?appends=logo',
});
if (result.loading) {
return <Spin />;
return render();
}
return <SystemSettingsContext.Provider value={result}>{props.children}</SystemSettingsContext.Provider>;
};

View File

@ -78,19 +78,17 @@ export const SettingsMenu: React.FC<{
},
{
key: 'reboot',
label: t('Reboot application'),
label: t('Restart application'),
onClick: async () => {
modal.confirm({
title: t('Reboot application'),
content: t('The will interrupt service, it may take a few seconds to restart. Are you sure to continue?'),
okText: t('Reboot'),
title: t('Restart application'),
// content: t('The will interrupt service, it may take a few seconds to restart. Are you sure to continue?'),
okText: t('Restart'),
okButtonProps: {
danger: true,
},
onOk: async () => {
await api.resource('app').reboot();
await check();
window.location.reload();
await api.resource('app').restart();
},
});
},

View File

@ -1,8 +1,8 @@
import { Spin } from 'antd';
import React, { createContext, useContext, useMemo } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useACLRoleContext } from '../acl';
import { ReturnTypeOfUseRequest, useRequest } from '../api-client';
import { useAppSpin } from '../application/hooks/useAppSpin';
import { useCompile } from '../schema-component';
export const CurrentUserContext = createContext<ReturnTypeOfUseRequest>(null);
@ -29,11 +29,12 @@ export const useCurrentRoles = () => {
};
export const CurrentUserProvider = (props) => {
const { render } = useAppSpin();
const result = useRequest<any>({
url: 'auth:check',
});
if (result.loading) {
return <Spin />;
return render();
}
return <CurrentUserContext.Provider value={result}>{props.children}</CurrentUserContext.Provider>;
};

View File

@ -35,6 +35,7 @@ function getUmiConfig() {
'process.env.API_BASE_URL': API_BASE_URL || API_BASE_PATH,
'process.env.APP_ENV': process.env.APP_ENV,
'process.env.VERSION': packageJson.version,
'process.env.WEBSOCKET_URL': process.env.WEBSOCKET_URL,
},
// only proxy when using `umi dev`
// if the assets are built, will not proxy

View File

@ -1,9 +1,9 @@
const getSubAppName = () => {
const match = window.location.pathname.match(/^\/apps\/([^/]*)\//);
const match = window.location.pathname.match(/^\/apps\/([^/]*)\/?/);
if (!match) {
return '';
}
return match[1].toUpperCase();
return match[1];
};
export default getSubAppName;

View File

@ -36,10 +36,15 @@
"lodash": "^4.17.21",
"multer": "^1.4.2",
"semver": "^7.3.7",
"xpipe": "^1.0.5"
"serve-handler": "^6.1.5",
"xpipe": "^1.0.5",
"ws": "^8.13.0",
"nanoid": "3.3.4"
},
"devDependencies": {
"@types/semver": "^7.3.9"
"@types/semver": "^7.3.9",
"@types/ws": "^8.5.5",
"@types/serve-handler": "^6.1.1"
},
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
}

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

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

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

View File

@ -47,7 +47,7 @@ describe('application', () => {
});
afterEach(async () => {
return app.db.close();
return app.destroy();
});
it('resourcer.define', async () => {
@ -126,5 +126,26 @@ describe('application', () => {
expect(response.body).toEqual([1, 2]);
});
it('should create application with plugins config', async () => {});
it('should call application with command', async () => {
await app.runCommand('start');
const jestFn = jest.fn();
app.on('beforeInstall', async () => {
jestFn();
});
const runningJest = jest.fn();
app.on('maintaining', ({ status }) => {
if (status === 'command_running') {
runningJest();
}
});
await app.runCommand('install');
expect(runningJest).toBeCalledTimes(1);
expect(jestFn).toBeCalledTimes(1);
});
});

View File

@ -35,6 +35,8 @@ describe('config', () => {
const app = new Application(config);
await app.load();
const appPluginA = app.getPlugin('test-a');
expect(appPluginA).toBeInstanceOf(TestA);

View File

@ -43,7 +43,7 @@ describe('application', () => {
});
afterEach(async () => {
return app.db.close();
return app.destroy();
});
it('resourcer.define', async () => {

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

View File

@ -29,7 +29,7 @@ describe('i18next', () => {
});
afterEach(async () => {
return app.db.close();
return app.destroy();
});
it('global', async () => {

View File

@ -2,14 +2,22 @@ import Application from '../application';
import { Plugin } from '../plugin';
describe('application life cycle', () => {
it('should start application', async () => {
const app = new Application({
let app: Application;
beforeEach(async () => {
app = new Application({
database: {
dialect: 'sqlite',
storage: ':memory:',
},
});
});
afterEach(async () => {
await app.destroy();
});
it('should start application', async () => {
const loadFn = jest.fn();
const installFn = jest.fn();
@ -35,19 +43,4 @@ describe('application life cycle', () => {
await app.install();
expect(installFn).toHaveBeenCalledTimes(1);
});
it('should listen application', async () => {
const app = new Application({
database: {
dialect: 'sqlite',
storage: ':memory:',
},
});
await app.start({ listen: { port: 13090 } });
expect(app.listenServer).not.toBeNull();
await app.stop();
expect(app.listenServer).toBeNull();
});
});

View File

@ -1,55 +1,8 @@
import { AppSupervisor } from '@nocobase/server';
import { mockServer, MockServer } from '@nocobase/test';
import { uid } from '@nocobase/utils';
import { IncomingMessage } from 'http';
import * as url from 'url';
import Application from '../application';
describe('multiple apps', () => {
it('should emit beforeGetApplication event', async () => {
const beforeGetApplicationFn = jest.fn();
const app = mockServer();
app.on('beforeGetApplication', beforeGetApplicationFn);
app.appManager.addSubApp(
new Application({
database: app.db,
name: 'sub1',
}),
);
app.appManager.setAppSelector(() => 'sub1');
await app.agent().resource('test').test({});
await app.agent().resource('test').test({});
expect(beforeGetApplicationFn).toHaveBeenCalledTimes(2);
await app.destroy();
});
it('should listen stop event', async () => {
const app = mockServer();
const subApp1 = app.appManager.addSubApp(
new Application({
database: app.db,
name: 'sub1',
}),
);
const subApp1StopFn = jest.fn();
subApp1.on('afterStop', subApp1StopFn);
await app.stop();
expect(subApp1StopFn).toBeCalledTimes(1);
});
});
describe('multiple application', () => {
let app: MockServer;
beforeEach(async () => {
@ -67,13 +20,11 @@ describe('multiple application', () => {
const sub2 = `a_${uid()}`;
const sub3 = `a_${uid()}`;
const subApp1 = app.appManager.addSubApp(
new Application({
const subApp1 = new Application({
database: app.db,
acl: false,
name: sub1,
}),
);
});
subApp1.resourcer.define({
name: 'test',
@ -84,13 +35,11 @@ describe('multiple application', () => {
},
});
const subApp2 = app.appManager.addSubApp(
new Application({
const subApp2 = new Application({
database: app.db,
acl: false,
name: sub2,
}),
);
});
subApp2.resourcer.define({
name: 'test',
@ -101,28 +50,8 @@ describe('multiple application', () => {
},
});
let response = await app.agent().resource('test').test();
expect(response.statusCode).toEqual(404);
app.appManager.setAppSelector((req: IncomingMessage) => {
const queryObject = url.parse(req.url, true).query;
return queryObject['app'] as string;
});
response = await app.agent().resource('test').test({
app: sub1,
});
expect(response.statusCode).toEqual(200);
response = await app.agent().resource('test').test({
app: sub2,
});
expect(response.statusCode).toEqual(200);
response = await app.agent().resource('test').test({
app: sub3,
});
expect(response.statusCode).toEqual(404);
expect(AppSupervisor.getInstance().hasApp(sub1)).toBeTruthy();
expect(AppSupervisor.getInstance().hasApp(sub2)).toBeTruthy();
expect(AppSupervisor.getInstance().hasApp(sub3)).toBeFalsy();
});
});

View File

@ -1,9 +1,8 @@
import { Application } from '../application';
import { mockServer, MockServer } from '@nocobase/test';
import { Plugin } from '../plugin';
import Plugin1 from './plugins/plugin1';
import Plugin2 from './plugins/plugin2';
import Plugin3 from './plugins/plugin3';
import { mockServer, MockServer } from '@nocobase/test';
describe('plugin', () => {
let app: MockServer;
@ -19,57 +18,6 @@ describe('plugin', () => {
await app.destroy();
});
describe('define', () => {
it('should add plugin with options', async () => {
class MyPlugin extends Plugin {
getName(): string {
return 'test';
}
}
const plugin = app.plugin(MyPlugin, {
test: 'hello',
});
expect(plugin.options['test']).toEqual('hello');
});
it('plugin name', async () => {
interface Options {
a?: string;
}
class MyPlugin extends Plugin<Options> {
async load() {
this.options.a;
}
getName(): string {
return 'MyPlugin';
}
}
const plugin = app.plugin<Options>(MyPlugin, {
a: 'aa',
});
plugin.setOptions({
a: 'a',
});
expect(plugin).toBeInstanceOf(MyPlugin);
expect(plugin.getName()).toBe('MyPlugin');
});
it('plugin name', async () => {
const plugin = app.plugin(Plugin1);
expect(plugin).toBeInstanceOf(Plugin);
expect(plugin.getName()).toBe('Plugin1');
});
it('plugin name', async () => {
const plugin = app.plugin(Plugin3);
expect(plugin).toBeInstanceOf(Plugin3);
expect(plugin.getName()).toBe('Plugin3');
});
});
describe('load', () => {
it('plugin load', async () => {
app.plugin(
@ -112,7 +60,7 @@ describe('plugin', () => {
});
});
describe('enable', function () {
describe.skip('enable', function () {
it('should call beforeEnable', async () => {
const beforeEnable = jest.fn();

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

View File

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

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

View File

@ -2,29 +2,31 @@ import { ACL } from '@nocobase/acl';
import { registerActions } from '@nocobase/actions';
import { actions as authActions, AuthManager } from '@nocobase/auth';
import { Cache, createCache, ICacheConfig } from '@nocobase/cache';
import Database, { Collection, CollectionOptions, IDatabaseOptions } from '@nocobase/database';
import Database, { CollectionOptions, IDatabaseOptions } from '@nocobase/database';
import { AppLoggerOptions, createAppLogger, Logger } from '@nocobase/logger';
import Resourcer, { ResourceOptions } from '@nocobase/resourcer';
import { applyMixins, AsyncEmitter, Toposort, ToposortOptions } from '@nocobase/utils';
import chalk from 'chalk';
import { Command, CommandOptions, ParseOptions } from 'commander';
import { Server } from 'http';
import { IncomingMessage, Server, ServerResponse } from 'http';
import { i18n, InitOptions } from 'i18next';
import Koa, { DefaultContext as KoaDefaultContext, DefaultState as KoaDefaultState } from 'koa';
import compose from 'koa-compose';
import semver from 'semver';
import { promisify } from 'util';
import lodash from 'lodash';
import { createACL } from './acl';
import { AppManager } from './app-manager';
import { AppSupervisor } from './app-supervisor';
import { registerCli } from './commands';
import { createI18n, createResourcer, registerMiddlewares } from './helper';
import { ApplicationNotInstall } from './errors/application-not-install';
import { createAppProxy, createI18n, createResourcer, getCommandFullName, registerMiddlewares } from './helper';
import { ApplicationVersion } from './helpers/application-version';
import { Locale } from './locale';
import { Plugin } from './plugin';
import { InstallOptions, PluginManager } from './plugin-manager';
const packageJson = require('../package.json');
export type PluginConfiguration = string | [string, any];
export type PluginType = string | typeof Plugin;
export type PluginConfiguration = PluginType | [PluginType, any];
export interface ResourcerOptions {
prefix?: string;
@ -61,14 +63,6 @@ export interface DefaultContext extends KoaDefaultContext {
[key: string]: any;
}
interface MiddlewareOptions {
name?: string;
resourceName?: string;
resourceNames?: string[];
insertBefore?: string;
insertAfter?: string;
}
interface ActionsOptions {
resourceName?: string;
resourceNames?: string[];
@ -92,251 +86,169 @@ interface ListenOptions {
interface StartOptions {
cliArgs?: any[];
dbSync?: boolean;
listen?: ListenOptions;
checkInstall?: boolean;
}
export class ApplicationVersion {
protected app: Application;
protected collection: Collection;
type MaintainingStatus = 'command_begin' | 'command_end' | 'command_running' | 'command_error';
constructor(app: Application) {
this.app = app;
if (!app.db.hasCollection('applicationVersion')) {
app.db.collection({
name: 'applicationVersion',
namespace: 'core.applicationVersion',
duplicator: 'required',
timestamps: false,
fields: [{ name: 'value', type: 'string' }],
});
}
this.collection = this.app.db.getCollection('applicationVersion');
}
async get() {
if (await this.app.db.collectionExistsInDb('applicationVersion')) {
const model = await this.collection.model.findOne();
if (!model) {
return null;
}
return model.get('value') as any;
}
return null;
}
async update() {
await this.collection.sync();
await this.collection.model.destroy({
truncate: true,
});
await this.collection.model.create({
value: this.app.getVersion(),
});
}
async satisfies(range: string) {
if (await this.app.db.collectionExistsInDb('applicationVersion')) {
const model: any = await this.collection.model.findOne();
const version = model?.value as any;
if (!version) {
return true;
}
return semver.satisfies(version, range, { includePrerelease: true });
}
return true;
}
}
export type MaintainingCommandStatus = {
command: {
name: string;
};
status: MaintainingStatus;
error?: Error;
};
export class Application<StateT = DefaultState, ContextT = DefaultContext> extends Koa implements AsyncEmitter {
protected _db: Database;
protected _logger: Logger;
protected _resourcer: Resourcer;
protected _cache: Cache;
protected _cli: Command;
protected _i18n: i18n;
protected _pm: PluginManager;
protected _acl: ACL;
protected _appManager: AppManager;
protected _authManager: AuthManager;
protected _locales: Locale;
protected _version: ApplicationVersion;
protected plugins = new Map<string, Plugin>();
public listenServer: Server;
declare middleware: any;
stopped = false;
ready = false;
declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
public rawOptions: ApplicationOptions;
public activatedCommand: {
name: string;
} = null;
public running = false;
protected plugins = new Map<string, Plugin>();
protected _appSupervisor: AppSupervisor = AppSupervisor.getInstance();
protected _started: boolean;
private _authenticated = false;
private _maintaining = false;
private _maintainingCommandStatus: MaintainingCommandStatus;
private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null;
constructor(public options: ApplicationOptions) {
super();
this.rawOptions = this.name == 'main' ? lodash.cloneDeep(options) : {};
this.init();
this._appSupervisor.addApp(this);
}
protected _loaded: boolean;
get loaded() {
return this._loaded;
}
private _maintainingMessage: string;
get maintainingMessage() {
return this._maintainingMessage;
}
protected _db: Database;
get db() {
return this._db;
}
get cache() {
return this._cache;
protected _logger: Logger;
get logger() {
return this._logger;
}
protected _resourcer: Resourcer;
get resourcer() {
return this._resourcer;
}
protected _cache: Cache;
get cache() {
return this._cache;
}
protected _cli: Command;
get cli() {
return this._cli;
}
get acl() {
return this._acl;
}
protected _i18n: i18n;
get i18n() {
return this._i18n;
}
protected _pm: PluginManager;
get pm() {
return this._pm;
}
get version() {
return this._version;
protected _acl: ACL;
get acl() {
return this._acl;
}
get appManager() {
return this._appManager;
}
protected _authManager: AuthManager;
get authManager() {
return this._authManager;
}
get logger() {
return this._logger;
protected _locales: Locale;
get locales() {
return this._locales;
}
protected _version: ApplicationVersion;
get version() {
return this._version;
}
get log() {
return this._logger;
}
get locales() {
return this._locales;
}
get name() {
return this.options.name || 'main';
}
protected init() {
const options = this.options;
const logger = createAppLogger(options.logger);
this._logger = logger.instance;
// @ts-ignore
this._events = [];
// @ts-ignore
this._eventsCount = [];
this.removeAllListeners();
this.middleware = new Toposort<any>();
this.plugins = new Map<string, Plugin>();
this._acl = createACL();
this.use(logger.middleware, { tag: 'logger' });
if (this._db) {
// MaxListenersExceededWarning
this._db.removeAllListeners();
isMaintaining() {
return this._maintaining;
}
this._db = this.createDatabase(options);
getMaintaining() {
return this._maintainingCommandStatus;
}
this._resourcer = createResourcer(options);
this._cli = new Command('nocobase').usage('[command] [options]');
this._i18n = createI18n(options);
this._cache = createCache(options.cache);
this.context.db = this._db;
this.context.logger = this._logger;
this.context.resourcer = this._resourcer;
this.context.cache = this._cache;
setMaintaining(_maintainingCommandStatus: MaintainingCommandStatus) {
this._maintainingCommandStatus = _maintainingCommandStatus;
if (this._pm) {
this._pm = this._pm.clone();
} else {
this._pm = new PluginManager({
app: this,
plugins: options.plugins,
this.emit('maintaining', _maintainingCommandStatus);
if (_maintainingCommandStatus.status == 'command_end') {
this._maintaining = false;
return;
}
this._maintaining = true;
}
setMaintainingMessage(message: string) {
this._maintainingMessage = message;
this.emit('maintainingMessageChanged', {
message: this._maintainingMessage,
maintainingStatus: this._maintainingCommandStatus,
});
}
if (this._appManager) {
this._appManager.bindMainApplication(this);
} else {
this._appManager = new AppManager(this);
}
this._authManager = new AuthManager({
authKey: 'X-Authenticator',
default: 'basic',
});
this.resource({
name: 'auth',
actions: authActions,
});
this._resourcer.use(this._authManager.middleware(), { tag: 'auth' });
if (this.options.acl !== false) {
this._resourcer.use(this._acl.middleware(), { tag: 'acl', after: ['auth'] });
}
this._locales = new Locale(this);
registerMiddlewares(this, options);
if (options.registerActions !== false) {
registerActions(this);
}
registerCli(this);
this._version = new ApplicationVersion(this);
}
private createDatabase(options: ApplicationOptions) {
const db = new Database({
...(options.database instanceof Database ? options.database.options : options.database),
migrator: {
context: { app: this },
},
});
db.setLogger(this._logger);
return db;
}
getVersion() {
return packageJson.version;
}
plugin<O = any>(pluginClass: any, options?: O): Plugin {
return this.pm.addStatic(pluginClass, options);
plugin<O = any>(pluginClass: any, options?: O) {
this.log.debug(`add plugin ${pluginClass.name}`);
this.pm.addPreset(pluginClass, options);
}
// @ts-ignore
@ -353,14 +265,12 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
if (!this.listenerCount('error')) this.on('error', this.onerror);
const handleRequest = (req, res) => {
return (req: IncomingMessage, res: ServerResponse) => {
const ctx = this.createContext(req, res);
// @ts-ignore
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
collection(options: CollectionOptions) {
@ -380,32 +290,54 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
}
findCommand(name: string): Command {
return (<any>this.cli)._findCommand(name);
return (this.cli as any)._findCommand(name);
}
async load(options?: any) {
if (this._loaded) {
return;
}
if (options?.reload) {
console.log(`Reload the ${this.name} application configuration`);
this.setMaintainingMessage('app reload');
this.log.info(`app.reload()`);
const oldDb = this._db;
this.init();
await oldDb.close();
}
this.setMaintainingMessage('init plugins');
await this.pm.initPlugins();
this.setMaintainingMessage('start load');
this.setMaintainingMessage('emit beforeLoad');
await this.emitAsync('beforeLoad', this, options);
await this.pm.load(options);
this.setMaintainingMessage('emit afterLoad');
await this.emitAsync('afterLoad', this, options);
this._loaded = true;
}
async reload(options?: any) {
this.log.debug(`start reload`);
this._loaded = false;
await this.load({
...options,
reload: true,
});
this.log.debug('emit afterReload');
this.setMaintainingMessage('emit afterReload');
await this.emitAsync('afterReload', this, options);
this.log.debug(`finish reload`);
}
getPlugin<P extends Plugin>(name: string) {
getPlugin<P extends Plugin>(name: string | typeof Plugin) {
return this.pm.get(name) as P;
}
@ -413,75 +345,129 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return this.runAsCLI(argv);
}
async runAsCLI(argv = process.argv, options?: ParseOptions) {
try {
async authenticate() {
if (this._authenticated) {
return;
}
this._authenticated = true;
await this.db.auth({ retry: 30 });
} catch (error) {
console.log(chalk.red(error.message));
process.exit(1);
await this.dbVersionCheck({ exit: true });
await this.db.prepare();
}
await this.dbVersionCheck({ exit: true });
async runCommand(command: string, ...args: any[]) {
return await this.runAsCLI([command, ...args], { from: 'user' });
}
await this.db.prepare();
createCli() {
return new Command('nocobase')
.usage('[command] [options]')
.hook('preAction', async (_, actionCommand) => {
this.activatedCommand = {
name: getCommandFullName(actionCommand),
};
if (argv?.[2] !== 'upgrade') {
await this.load({
method: argv?.[2],
this.setMaintaining({
status: 'command_begin',
command: this.activatedCommand,
});
this.setMaintaining({
status: 'command_running',
command: this.activatedCommand,
});
await this.authenticate();
await this.load();
})
.hook('postAction', async (_, actionCommand) => {
if (this._maintainingStatusBeforeCommand?.error && this._started) {
await this.restart();
}
});
}
return this.cli.parseAsync(argv, options);
async runAsCLI(argv = process.argv, options?: ParseOptions) {
if (this.activatedCommand) {
return;
}
this._maintainingStatusBeforeCommand = this._maintainingCommandStatus;
try {
const command = await this.cli.parseAsync(argv, options);
this.setMaintaining({
status: 'command_end',
command: this.activatedCommand,
});
return command;
} catch (error) {
console.log(`run command ${this.activatedCommand.name} error:`, error);
this.setMaintaining({
status: 'command_error',
command: this.activatedCommand,
error,
});
} finally {
this.activatedCommand = null;
}
}
async start(options: StartOptions = {}) {
if (this._started) {
return;
}
this._started = true;
if (options.checkInstall && !(await this.isInstalled())) {
throw new ApplicationNotInstall(
`Application ${this.name} is not installed, Please run 'yarn nocobase install' command first`,
);
}
this.setMaintainingMessage('starting app...');
if (this.db.closed()) {
await this.db.reconnect();
}
if (options.dbSync) {
console.log('db sync...');
await this.db.sync();
}
this.setMaintainingMessage('emit beforeStart');
await this.emitAsync('beforeStart', this, options);
if (options?.listen?.port) {
const pmServer = await this.pm.listen();
const listen = () =>
new Promise((resolve, reject) => {
const Server = this.listen(options?.listen, () => {
resolve(Server);
});
Server.on('error', (err) => {
reject(err);
});
Server.on('close', () => {
pmServer.close();
});
});
try {
//@ts-ignore
this.listenServer = await listen();
} catch (e) {
console.error(e);
process.exit(1);
}
}
this.setMaintainingMessage('emit afterStart');
await this.emitAsync('afterStart', this, options);
await this.emitAsync('__started', this, options);
this.stopped = false;
}
listen(...args): Server {
return this.appManager.listen(...args);
async isStarted() {
return this._started;
}
async tryReloadOrRestart() {
if (this._started) {
await this.restart();
} else {
await this.reload();
}
}
async restart(options: StartOptions = {}) {
if (!this._started) {
return;
}
this._started = false;
await this.reload(options);
await this.start(options);
this.emit('__restarted', this, options);
}
async stop(options: any = {}) {
this.log.debug('stop app...');
this.setMaintainingMessage('stopping app...');
if (this.stopped) {
this.log.warn(`Application ${this.name} already stopped`);
return;
@ -489,31 +475,33 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
await this.emitAsync('beforeStop', this, options);
// close http server
if (this.listenServer) {
await promisify(this.listenServer.close).call(this.listenServer);
this.listenServer = null;
}
try {
// close database connection
// silent if database already closed
if (!this.db.closed()) {
this.logger.info(`close db`);
await this.db.close();
}
} catch (e) {
console.log(e);
this.log.error(e);
}
await this.emitAsync('afterStop', this, options);
this.stopped = true;
console.log(`${this.name} is stopped`);
this.log.info(`${this.name} is stopped`);
this._started = false;
}
async destroy(options: any = {}) {
this.logger.debug('start destroy app');
this.setMaintainingMessage('destroying app...');
await this.emitAsync('beforeDestroy', this, options);
await this.stop(options);
this.logger.debug('emit afterDestroy');
await this.emitAsync('afterDestroy', this, options);
this.logger.debug('finish destroy app');
}
async dbVersionCheck(options?: { exit?: boolean }) {
@ -554,19 +542,37 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
}
async install(options: InstallOptions = {}) {
console.log('Database dialect: ' + this.db.sequelize.getDialect());
this.setMaintainingMessage('installing app...');
this.log.debug('Database dialect: ' + this.db.sequelize.getDialect());
if (options?.clean || options?.sync?.force) {
console.log('Truncate database and reload app configuration');
this.log.debug('truncate database');
await this.db.clean({ drop: true });
await this.reload({ method: 'install' });
this.log.debug('app reloading');
await this.reload();
} else if (await this.isInstalled()) {
this.log.warn('app is installed');
return;
}
this.log.debug('emit beforeInstall');
this.setMaintainingMessage('call beforeInstall hook...');
await this.emitAsync('beforeInstall', this, options);
await this.db.sync();
this.log.debug('start install plugins');
await this.pm.install(options);
this.log.debug('update version');
await this.version.update();
this.log.debug('emit afterInstall');
this.setMaintainingMessage('call afterInstall hook...');
await this.emitAsync('afterInstall', this, options);
if (this._maintainingStatusBeforeCommand?.error) {
return;
}
if (this._started) {
await this.restart();
}
}
async upgrade(options: any = {}) {
@ -581,15 +587,113 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
});
await this.version.update();
await this.emitAsync('afterUpgrade', this, options);
this.log.debug(chalk.green(`✨ NocoBase has been upgraded to v${this.getVersion()}`));
if (this._started) {
await this.restart();
}
}
declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
toJSON() {
return {
appName: this.name,
name: this.name,
};
}
reInitEvents() {
for (const eventName of this.eventNames()) {
for (const listener of this.listeners(eventName)) {
if (listener['_reinitializable']) {
this.removeListener(eventName, listener as any);
}
}
}
}
protected init() {
const options = this.options;
const logger = createAppLogger({
...options.logger,
defaultMeta: {
app: this.name,
},
});
this._logger = logger.instance;
this.reInitEvents();
this.middleware = new Toposort<any>();
this.plugins = new Map<string, Plugin>();
this._acl = createACL();
this.use(logger.middleware, { tag: 'logger' });
if (this._db) {
// MaxListenersExceededWarning
this._db.removeAllListeners();
}
this._db = this.createDatabase(options);
this._resourcer = createResourcer(options);
this._cli = this.createCli();
this._i18n = createI18n(options);
this._cache = createCache(options.cache);
this.context.db = this._db;
this.context.logger = this._logger;
this.context.resourcer = this._resourcer;
this.context.cache = this._cache;
const plugins = this._pm ? this._pm.options.plugins : options.plugins;
this._pm = new PluginManager({
app: this,
plugins: plugins || [],
});
this._authManager = new AuthManager({
authKey: 'X-Authenticator',
default: 'basic',
});
this.resource({
name: 'auth',
actions: authActions,
});
this._resourcer.use(this._authManager.middleware(), { tag: 'auth' });
if (this.options.acl !== false) {
this._resourcer.use(this._acl.middleware(), { tag: 'acl', after: ['auth'] });
}
this._locales = new Locale(createAppProxy(this));
registerMiddlewares(this, options);
if (options.registerActions !== false) {
registerActions(this);
}
registerCli(this);
this._version = new ApplicationVersion(this);
}
private createDatabase(options: ApplicationOptions) {
const db = new Database({
...(options.database instanceof Database ? options.database.options : options.database),
migrator: {
context: { app: this },
},
});
db.setLogger(this._logger);
return db;
}
}
applyMixins(Application, [AsyncEmitter]);

View File

@ -11,8 +11,5 @@ export default (app: Application) => {
drop: force,
},
});
await app.stop({
cliArgs,
});
});
};

View File

@ -0,0 +1,9 @@
import Application from '../application';
export default (app: Application) => {
app.command('destroy').action(async (...cliArgs) => {
await app.destroy({
cliArgs,
});
});
};

View File

@ -8,6 +8,9 @@ export function registerCli(app: Application) {
require('./install').default(app);
require('./migrator').default(app);
require('./start').default(app);
require('./restart').default(app);
require('./stop').default(app);
require('./destroy').default(app);
require('./upgrade').default(app);
require('./pm').default(app);

View File

@ -1,4 +1,3 @@
import chalk from 'chalk';
import Application from '../application';
export default (app: Application) => {
@ -6,40 +5,8 @@ export default (app: Application) => {
.command('install')
.option('-f, --force')
.option('-c, --clean')
.option('-s, --silent')
.option('-r, --retry [retry]')
.option('-I, --ignore-installed')
.action(async (...cliArgs) => {
let installed = false;
const [opts] = cliArgs;
if (opts.ignoreInstalled) {
if (await app.isInstalled()) {
console.log('Application installed');
return;
}
}
if (!opts?.clean && !opts?.force) {
if (await app.isInstalled()) {
installed = true;
if (!opts.silent) {
console.log('NocoBase is already installed. To reinstall, please execute:');
console.log();
const command = '$ yarn nocobase install -f';
console.log(chalk.yellow(command));
console.log();
console.log(chalk.red('This operation will clear the database!!!'));
console.log();
}
return;
}
}
if (!opts.silent || !installed) {
console.log(`Start installing NocoBase`);
}
await app.install({
cliArgs,
clean: opts.clean,
@ -47,9 +14,5 @@ export default (app: Application) => {
force: opts.force,
},
});
await app.stop({
cliArgs,
});
});
};

View File

@ -1,18 +1,51 @@
import Application from '../application';
export default (app: Application) => {
app
.command('pm')
.argument('<method>')
.arguments('<plugins...>')
.option('-S, --skip-yarn-install', 'skip yarn install')
.action(async (method, plugins, options, ...args) => {
if (method === 'add' && !options.skipYarnInstall) {
const { run } = require('@nocobase/cli/src/util');
console.log('Install dependencies and rebuild workspaces');
await run('yarn', ['install']);
}
const pm = app.command('pm');
app.pm.clientWrite({ method, plugins });
pm.command('create')
.arguments('plugin')
.action(async (plugin) => {
await app.pm.create(plugin);
});
pm.command('add')
.arguments('plugin')
.action(async (plugin) => {
await app.pm.add(plugin);
});
pm.command('enable')
.arguments('<plugins...>')
.action(async (plugins) => {
try {
await app.pm.enable(plugins);
} catch (error) {
app.log.debug(`Failed to enable plugin: ${error.message}`);
app.setMaintainingMessage(`Failed to enable plugin: ${error.message}`);
await new Promise((resolve) => {
setTimeout(() => resolve(null), 10000);
});
}
});
pm.command('disable')
.arguments('<plugins...>')
.action(async (plugins) => {
try {
await app.pm.disable(plugins);
} catch (error) {
app.log.debug(`Failed to disable plugin: ${error.message}`);
app.setMaintainingMessage(`Failed to disable plugin: ${error.message}`);
await new Promise((resolve) => {
setTimeout(() => resolve(null), 10000);
});
}
});
pm.command('remove')
.arguments('<plugins...>')
.action(async (plugins) => {
await app.pm.remove(plugins);
});
};

View File

@ -0,0 +1,9 @@
import Application from '../application';
export default (app: Application) => {
app.command('restart').action(async (...cliArgs) => {
await app.restart({
cliArgs,
});
});
};

View File

@ -3,26 +3,24 @@ import Application from '../application';
export default (app: Application) => {
app
.command('start')
.option('-s, --silent')
.option('-p, --port [post]')
.option('-h, --host [host]')
.option('--db-sync')
.option('--quickstart')
.action(async (...cliArgs) => {
const [opts] = cliArgs;
const port = opts.port || process.env.APP_PORT || 13000;
const host = opts.host || process.env.APP_HOST || '0.0.0.0';
if (opts.quickstart) {
if (await app.isInstalled()) {
app.log.debug('installed....');
await app.upgrade();
} else {
await app.install();
}
}
await app.start({
dbSync: opts?.dbSync,
cliArgs,
listen: {
port,
host,
},
checkInstall: true,
});
if (!opts.silent) {
console.log(`🚀 NocoBase server running at: http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/`);
}
});
};

View File

@ -0,0 +1,9 @@
import Application from '../application';
export default (app: Application) => {
app.command('stop').action(async (...cliArgs) => {
await app.stop({
cliArgs,
});
});
};

View File

@ -9,9 +9,6 @@ export default (app: Application) => {
const [opts] = cliArgs;
console.log('upgrading...');
await app.upgrade();
await app.stop({
cliArgs,
});
console.log(chalk.green(`✨ NocoBase has been upgraded to v${app.getVersion()}`));
});
};

View File

@ -0,0 +1,9 @@
export class ApplicationNotInstall extends Error {
code: string;
constructor(message) {
super(message);
this.code = 'APP_NOT_INSTALLED_ERROR';
}
}

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

View File

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

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

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

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

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

View File

@ -1,6 +1,7 @@
import cors from '@koa/cors';
import Database from '@nocobase/database';
import Resourcer from '@nocobase/resourcer';
import { Command } from 'commander';
import i18next from 'i18next';
import bodyParser from 'koa-bodyparser';
import Application, { ApplicationOptions } from './application';
@ -78,3 +79,31 @@ export function registerMiddlewares(app: Application, options: ApplicationOption
app.use(db2resource, { tag: 'db2resource', after: 'dataWrapping' });
app.use(app.resourcer.restApiMiddleware(), { tag: 'restApi', after: 'db2resource' });
}
export const createAppProxy = (app: Application) => {
return new Proxy(app, {
get(target, prop, ...args) {
if (typeof prop === 'string' && ['on', 'once', 'addListener'].includes(prop)) {
return (eventName: string, listener: any) => {
listener['_reinitializable'] = true;
return target[prop](eventName, listener);
};
}
return Reflect.get(target, prop, ...args);
},
});
};
export const getCommandFullName = (command: Command) => {
const names = [];
names.push(command.name());
let parent = command?.parent;
while (parent) {
if (!parent?.parent) {
break;
}
names.unshift(parent.name());
parent = parent.parent;
}
return names.join('.');
};

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

View File

@ -1,4 +1,3 @@
export { AppManager } from './app-manager';
export * from './application';
export { Application as default } from './application';
export * as middlewares from './middlewares';
@ -6,3 +5,5 @@ export * from './migration';
export * from './plugin';
export * from './plugin-manager';
export * from './read-config';
export * from './gateway';
export * from './app-supervisor';

View File

@ -14,11 +14,17 @@ export class Locale {
this.app = app;
this.cache = createCache();
this.app.on('afterLoad', () => this.load());
this.app.on('afterLoad', async () => {
this.app.log.debug('load locale resource');
this.app.setMaintainingMessage('load locale resource');
await this.load();
this.app.log.debug('locale resource loaded');
this.app.setMaintainingMessage('locale resource loaded');
});
}
load() {
this.getCacheResources(this.defaultLang);
async load() {
await this.get(this.defaultLang);
}
setLocaleFn(name: string, fn: (lang: string) => Promise<any>) {
@ -30,6 +36,7 @@ export class Locale {
resources: await this.getCacheResources(lang),
};
for (const [name, fn] of this.localeFn) {
// this.app.log.debug(`load [${name}] locale resource `);
const result = await this.wrapCache(`locale:${name}:${lang}`, async () => await fn(lang));
if (result) {
defaults[name] = result;
@ -57,10 +64,12 @@ export class Locale {
getResources(lang: string) {
const resources = {};
const plugins = this.app.pm.getPlugins();
for (const name of plugins.keys()) {
const names = this.app.pm.getAliases();
for (const name of names) {
try {
const packageName = PluginManager.getPackageName(name);
// this.app.log.debug(`load [${packageName}] locale resource `);
// this.app.setMaintainingMessage(`load [${packageName}] locale resource `);
const res = getResource(packageName, lang);
if (res) {
resources[name] = { ...res };
@ -69,10 +78,6 @@ export class Locale {
// empty
}
}
const res = getResource('@nocobase/client', lang, false);
if (res) {
resources['client'] = { ...(resources['client'] || {}), ...res };
}
return resources;
}
}

View File

@ -1,10 +1,4 @@
import fs from 'fs';
import send from 'koa-send';
import path from 'path';
const PREFIX = '/api/plugins/client/';
const cwd = process.cwd();
const NODE_MODULES = path.join(cwd, 'node_modules');
/**
* get plugin client static file url
@ -16,72 +10,3 @@ const NODE_MODULES = path.join(cwd, 'node_modules');
export const getPackageClientStaticUrl = (packageName: string, filePath: string) => {
return `${PREFIX}${packageName}/${filePath}`;
};
const isMatchClientStaticUrl = (url: string) => {
return url.startsWith(PREFIX);
};
/**
* get package name from url
*
* @example
* /api/plugins/client/@nocobase/plugin-acl/index.js => @nocobase/plugin-acl
* /api/plugins/client/my-plugin/README.md => my-plugin
*/
const getPackageName = (url: string) => {
const urlArr = url.split('/');
return urlArr[4].startsWith('@') ? `${urlArr[4]}/${urlArr[5]}` : urlArr[4];
};
/**
* get plugin client static file real path
*
* @example
* /api/plugins/client/@nocobase/plugin-acl/index.js => /node_modules/@nocobase/plugin-acl/dist/client/index.js
* /api/plugins/client/my-plugin/README.md => /node_modules/my-plugin/dist/client/README.md
*/
const getRealPath = (packageName: string, url: string) => {
const ext = path.extname(url);
const filePath = url.replace(`${PREFIX}${packageName}/`, '');
if (ext.toLowerCase() === '.md') {
return path.join(NODE_MODULES, packageName, filePath);
} else {
return path.join(NODE_MODULES, packageName, 'dist', 'client', filePath);
}
};
/**
* send plugin client static file to browser.
*
* such as:
* /api/plugins/client/@nocobase/plugin-xxx/index.js
* /api/plugins/client/xxx/README.md
*/
export const clientStaticMiddleware = async (ctx, next) => {
if (isMatchClientStaticUrl(ctx.path)) {
// TODO: check packageName in plugins
const packageName = getPackageName(ctx.path);
const realPath = getRealPath(packageName, ctx.path);
// get file stats
const stats = await fs.promises.stat(realPath);
const ifModifiedSince = ctx.get('If-Modified-Since');
const lastModified = stats.mtime.toUTCString();
// check cache headers
if (ifModifiedSince === lastModified) {
ctx.status = 304;
return;
}
// `send` only accept relative path
const relativePath = path.relative(cwd, realPath);
await send(ctx, relativePath, {
setHeaders: (res) => {
res.setHeader('Last-Modified', lastModified);
},
});
}
await next();
};

View File

@ -1,3 +1,5 @@
import Application from '../../application';
export default {
name: 'pm',
actions: {
@ -14,10 +16,11 @@ export default {
async enable(ctx, next) {
const pm = ctx.app.pm;
const { filterByTk } = ctx.action.params;
const app = ctx.app as Application;
if (!filterByTk) {
ctx.throw(400, 'plugin name invalid');
}
await pm.enable(filterByTk);
app.runAsCLI(['pm', 'enable', filterByTk], { from: 'user' });
ctx.body = filterByTk;
await next();
},
@ -27,7 +30,8 @@ export default {
if (!filterByTk) {
ctx.throw(400, 'plugin name invalid');
}
await pm.disable(filterByTk);
const app = ctx.app as Application;
app.runAsCLI(['pm', 'disable', filterByTk], { from: 'user' });
ctx.body = filterByTk;
await next();
},
@ -41,7 +45,8 @@ export default {
if (!filterByTk) {
ctx.throw(400, 'plugin name invalid');
}
await pm.remove(filterByTk);
const app = ctx.app as Application;
app.runAsCLI(['pm', 'remove', filterByTk], { from: 'user' });
ctx.body = filterByTk;
await next();
},

View File

@ -1,4 +1,5 @@
import { Repository } from '@nocobase/database';
import lodash from 'lodash';
import { PluginManager } from './plugin-manager';
export class PluginManagerRepository extends Repository {
@ -17,13 +18,13 @@ export class PluginManagerRepository extends Repository {
}
async enable(name: string | string[]) {
const pluginNames = typeof name === 'string' ? [name] : name;
const plugins = pluginNames.map((name) => this.pm.plugins.get(name));
const pluginNames = lodash.castArray(name);
const plugins = pluginNames.map((name) => this.pm.get(name));
for (const plugin of plugins) {
const requiredPlugins = plugin.requiredPlugins();
for (const requiredPluginName of requiredPlugins) {
const requiredPlugin = this.pm.plugins.get(requiredPluginName);
const requiredPlugin = this.pm.get(requiredPluginName);
if (!requiredPlugin.enabled) {
throw new Error(`${plugin.name} plugin need ${requiredPluginName} plugin enabled`);
}
@ -47,11 +48,17 @@ export class PluginManagerRepository extends Repository {
}
async disable(name: string | string[]) {
const pluginNames = typeof name === 'string' ? [name] : name;
await this.update({
filter: {
name = lodash.cloneDeep(name);
const pluginNames = lodash.castArray(name);
console.log(`disable ${name}, ${pluginNames}`);
const filter = {
name,
},
};
console.log(JSON.stringify(filter, null, 2));
await this.update({
filter,
values: {
enabled: false,
installed: false,
@ -60,19 +67,38 @@ export class PluginManagerRepository extends Repository {
return pluginNames;
}
async load() {
async getItems() {
try {
// sort plugins by id
const items = await this.find({
return await this.find({
sort: 'id',
});
} catch (error) {
await this.collection.sync({
alter: {
drop: false,
},
force: false,
});
return await this.find({
sort: 'id',
});
}
}
async init() {
const exists = await this.collection.existsInDb();
if (!exists) {
return;
}
const items = await this.getItems();
for (const item of items) {
await this.pm.addStatic(item.get('name'), {
...item.get('options'),
name: item.get('name'),
version: item.get('version'),
enabled: item.get('enabled'),
async: true,
const { options, ...others } = item.toJSON();
await this.pm.add(item.get('name'), {
...others,
...options,
});
}
}

View File

@ -1,13 +1,12 @@
import { CleanOptions, Collection, SyncOptions } from '@nocobase/database';
import { requireModule } from '@nocobase/utils';
import execa from 'execa';
import fs from 'fs';
import _ from 'lodash';
import net from 'net';
import { resolve } from 'path';
import xpipe from 'xpipe';
import Application from '../application';
import { createAppProxy } from '../helper';
import { Plugin } from '../plugin';
import { clientStaticMiddleware } from './clientStaticMiddleware';
import collectionOptions from './options/collection';
import resourceOptions from './options/resource';
import { PluginManagerRepository } from './plugin-manager-repository';
@ -23,31 +22,28 @@ export interface InstallOptions {
sync?: SyncOptions;
}
export class AddPresetError extends Error {}
export class PluginManager {
app: Application;
collection: Collection;
repository: PluginManagerRepository;
plugins = new Map<string, Plugin>();
_repository: PluginManagerRepository;
pluginInstances = new Map<typeof Plugin, Plugin>();
pluginAliases = new Map<string, Plugin>();
server: net.Server;
pmSock: string;
_tmpPluginArgs = [];
constructor(options: PluginManagerOptions) {
constructor(public options: PluginManagerOptions) {
this.app = options.app;
const f = resolve(process.cwd(), 'storage', 'pm.sock');
this.pmSock = xpipe.eq(this.app.options.pmSock || f);
this.app.db.registerRepositories({
PluginManagerRepository,
});
this.collection = this.app.db.collection(collectionOptions);
this.repository = this.collection.repository as PluginManagerRepository;
this.repository.setPluginManager(this);
this._repository = this.collection.repository as PluginManagerRepository;
this._repository.setPluginManager(this);
this.app.resourcer.define(resourceOptions);
this.app.use(clientStaticMiddleware);
this.app.resourcer.use(async (ctx, next) => {
await next();
const { resourceName, actionName } = ctx.action;
@ -72,297 +68,10 @@ export class PluginManager {
name: 'pm',
actions: ['pm:*', 'applicationPlugins:list'],
});
this.app.on('beforeLoad', async (app, options) => {
if (options?.method && ['install', 'upgrade'].includes(options.method)) {
await this.collection.sync();
}
const exists = await this.app.db.collectionExistsInDb('applicationPlugins');
if (!exists) {
this.app.log.warn(`applicationPlugins collection not exists in ${this.app.name}`);
return;
}
if (options?.method !== 'install' || options.reload) {
await this.repository.load();
}
});
this.app.on('beforeUpgrade', async () => {
await this.collection.sync();
});
this.addStaticMultiple(options.plugins);
}
addStaticMultiple(plugins: any) {
for (const plugin of plugins || []) {
if (typeof plugin == 'string') {
this.addStatic(plugin);
} else {
this.addStatic(...plugin);
}
}
}
getPlugins() {
return this.plugins;
}
get(name: string) {
return this.plugins.get(name);
}
has(name: string) {
return this.plugins.has(name);
}
clientWrite(data: any) {
const { method, plugins } = data;
if (method === 'create') {
try {
console.log(method, plugins);
this[method](plugins);
} catch (error) {
console.error(error.message);
}
return;
}
const client = new net.Socket();
client.connect(this.pmSock, () => {
client.write(JSON.stringify(data));
client.end();
});
client.on('error', async () => {
try {
console.log(method, plugins);
await this[method](plugins);
} catch (error) {
console.error(error.message);
}
});
}
async listen(): Promise<net.Server> {
this.server = net.createServer((socket) => {
socket.on('data', async (data) => {
const { method, plugins } = JSON.parse(data.toString());
try {
console.log(method, plugins);
await this[method](plugins);
} catch (error) {
console.error(error.message);
}
});
socket.pipe(socket);
});
if (fs.existsSync(this.pmSock)) {
await fs.promises.unlink(this.pmSock);
}
return new Promise((resolve) => {
this.server.listen(this.pmSock, () => {
resolve(this.server);
});
});
}
async create(name: string | string[]) {
console.log('creating...');
const pluginNames = Array.isArray(name) ? name : [name];
const { run } = require('@nocobase/cli/src/util');
const createPlugin = async (name) => {
const { PluginGenerator } = require('@nocobase/cli/src/plugin-generator');
const generator = new PluginGenerator({
cwd: resolve(process.cwd(), name),
args: {},
context: {
name,
},
});
await generator.run();
};
await Promise.all(pluginNames.map((pluginName) => createPlugin(pluginName)));
await run('yarn', ['install']);
}
clone() {
const pm = new PluginManager({
app: this.app,
});
for (const arg of this._tmpPluginArgs) {
pm.addStatic(...arg);
}
return pm;
}
addStatic(plugin?: any, options?: any) {
if (!options?.async) {
this._tmpPluginArgs.push([plugin, options]);
}
let name: string;
if (typeof plugin === 'string') {
name = plugin;
plugin = PluginManager.resolvePlugin(plugin);
} else {
name = plugin.name;
if (!name) {
throw new Error(`plugin name invalid`);
}
}
const instance = new plugin(this.app, {
name,
enabled: true,
...options,
});
const pluginName = instance.getName();
if (this.plugins.has(pluginName)) {
throw new Error(`plugin name [${pluginName}] already exists`);
}
this.plugins.set(pluginName, instance);
return instance;
}
async add(plugin: any, options: any = {}, transaction?: any) {
if (Array.isArray(plugin)) {
const t = transaction || (await this.app.db.sequelize.transaction());
try {
const items = [];
for (const p of plugin) {
items.push(await this.add(p, options, t));
}
await t.commit();
return items;
} catch (error) {
await t.rollback();
throw error;
}
}
const packageName = await PluginManager.findPackage(plugin);
const instance = this.addStatic(plugin, {
...options,
async: true,
});
const model = await this.repository.findOne({
transaction,
filter: { name: plugin },
});
const packageJson = PluginManager.getPackageJson(packageName);
if (!model) {
const { enabled, builtIn, installed, ...others } = options;
await this.repository.create({
transaction,
values: {
name: plugin,
version: packageJson.version,
enabled: !!enabled,
builtIn: !!builtIn,
installed: !!installed,
options: {
...others,
},
},
});
}
return instance;
}
async load(options: any = {}) {
for (const [name, plugin] of this.plugins) {
if (!plugin.enabled) {
continue;
}
await plugin.beforeLoad();
}
for (const [name, plugin] of this.plugins) {
if (!plugin.enabled) {
continue;
}
await this.app.emitAsync('beforeLoadPlugin', plugin, options);
await plugin.load();
await this.app.emitAsync('afterLoadPlugin', plugin, options);
}
}
async install(options: InstallOptions = {}) {
for (const [name, plugin] of this.plugins) {
if (!plugin.enabled) {
continue;
}
await this.app.emitAsync('beforeInstallPlugin', plugin, options);
await plugin.install(options);
await this.app.emitAsync('afterInstallPlugin', plugin, options);
}
}
async enable(name: string | string[]) {
try {
const pluginNames = await this.repository.enable(name);
await this.app.reload();
await this.app.db.sync();
for (const pluginName of pluginNames) {
const plugin = this.app.getPlugin(pluginName);
if (!plugin) {
throw new Error(`${name} plugin does not exist`);
}
if (!plugin.options.installed) {
await plugin.install();
plugin.options.installed = true;
}
await plugin.afterEnable();
}
await this.app.emitAsync('afterEnablePlugin', name);
} catch (error) {
throw error;
}
}
async disable(name: string | string[]) {
try {
const pluginNames = await this.repository.disable(name);
await this.app.reload();
for (const pluginName of pluginNames) {
const plugin = this.app.getPlugin(pluginName);
if (!plugin) {
throw new Error(`${name} plugin does not exist`);
}
await plugin.afterDisable();
}
await this.app.emitAsync('afterDisablePlugin', name);
} catch (error) {
throw error;
}
}
async remove(name: string | string[]) {
const pluginNames = typeof name === 'string' ? [name] : name;
for (const pluginName of pluginNames) {
const plugin = this.app.getPlugin(pluginName);
if (!plugin) {
throw new Error(`${name} plugin does not exist`);
}
await plugin.remove();
}
await this.repository.remove(name);
this.app.reload();
get repository() {
return this.app.db.getRepository('applicationPlugins') as PluginManagerRepository;
}
static getPackageJson(packageName: string) {
@ -412,9 +121,354 @@ export class PluginManager {
throw new Error(`No available packages found, ${name} plugin does not exist`);
}
static resolvePlugin(pluginName: string) {
static resolvePlugin(pluginName: string | typeof Plugin) {
if (typeof pluginName === 'string') {
const packageName = this.getPackageName(pluginName);
return requireModule(packageName);
} else {
return pluginName;
}
}
addPreset(plugin: string | typeof Plugin, options: any = {}) {
if (this.app.loaded) {
throw new AddPresetError('must be added before executing app.load()');
}
if (!this.options.plugins) {
this.options.plugins = [];
}
this.options.plugins.push([plugin, options]);
}
getPlugins() {
return this.pluginInstances;
}
getAliases() {
return this.pluginAliases.keys();
}
get(name: string | typeof Plugin) {
if (typeof name === 'string') {
return this.pluginAliases.get(name);
}
return this.pluginInstances.get(name);
}
has(name: string | typeof Plugin) {
if (typeof name === 'string') {
return this.pluginAliases.has(name);
}
return this.pluginInstances.has(name);
}
del(name: string | typeof Plugin) {
const instance = this.get(name);
if (instance) {
this.pluginAliases.delete(instance.name);
this.pluginInstances.delete(instance.constructor as typeof Plugin);
}
}
async create(name: string | string[]) {
console.log('creating...');
const pluginNames = Array.isArray(name) ? name : [name];
const { run } = require('@nocobase/cli/src/util');
const createPlugin = async (name) => {
const { PluginGenerator } = require('@nocobase/cli/src/plugin-generator');
const generator = new PluginGenerator({
cwd: resolve(process.cwd(), name),
args: {},
context: {
name,
},
});
await generator.run();
};
await Promise.all(pluginNames.map((pluginName) => createPlugin(pluginName)));
await run('yarn', ['install']);
}
async add(plugin?: any, options: any = {}) {
if (this.has(plugin)) {
const name = typeof plugin === 'string' ? plugin : plugin.name;
this.app.log.warn(`plugin [${name}] added`);
return;
}
if (!options.name && typeof plugin === 'string') {
options.name = plugin;
}
this.app.log.debug(`adding plugin [${options.name}]...`);
let P: any;
try {
P = PluginManager.resolvePlugin(plugin);
} catch (error) {
this.app.log.warn('plugin not found', error);
return;
}
const instance: Plugin = new P(createAppProxy(this.app), options);
this.pluginInstances.set(P, instance);
if (options.name) {
this.pluginAliases.set(options.name, instance);
}
await instance.afterAdd();
}
async initPlugins() {
await this.initPresetPlugins();
await this.repository.init();
}
async load(options: any = {}) {
this.app.setMaintainingMessage('loading plugins...');
const total = this.pluginInstances.size;
let current = 0;
for (const [P, plugin] of this.getPlugins()) {
if (plugin.state.loaded) {
continue;
}
const name = P.name;
current += 1;
this.app.setMaintainingMessage(`before load plugin [${name}], ${current}/${total}`);
if (!plugin.enabled) {
continue;
}
this.app.logger.debug(`before load plugin [${name}]...`);
await plugin.beforeLoad();
}
current = 0;
for (const [P, plugin] of this.getPlugins()) {
if (plugin.state.loaded) {
continue;
}
const name = P.name;
current += 1;
this.app.setMaintainingMessage(`load plugin [${name}], ${current}/${total}`);
if (!plugin.enabled) {
continue;
}
await this.app.emitAsync('beforeLoadPlugin', plugin, options);
this.app.logger.debug(`loading plugin [${name}]...`);
await plugin.load();
plugin.state.loaded = true;
await this.app.emitAsync('afterLoadPlugin', plugin, options);
this.app.logger.debug(`after load plugin [${name}]...`);
}
this.app.setMaintainingMessage('loaded plugins');
}
async install(options: InstallOptions = {}) {
this.app.setMaintainingMessage('install plugins...');
const total = this.pluginInstances.size;
let current = 0;
this.app.log.debug('call db.sync()');
await this.app.db.sync();
const toBeUpdated = [];
for (const [P, plugin] of this.getPlugins()) {
if (plugin.state.installing || plugin.state.installed) {
continue;
}
const name = P.name;
current += 1;
if (!plugin.enabled) {
continue;
}
plugin.state.installing = true;
this.app.setMaintainingMessage(`before install plugin [${name}], ${current}/${total}`);
await this.app.emitAsync('beforeInstallPlugin', plugin, options);
this.app.logger.debug(`install plugin [${name}]...`);
await plugin.install(options);
toBeUpdated.push(name);
plugin.state.installing = false;
plugin.state.installed = true;
plugin.installed = true;
this.app.setMaintainingMessage(`after install plugin [${name}], ${current}/${total}`);
await this.app.emitAsync('afterInstallPlugin', plugin, options);
}
await this.repository.update({
filter: {
name: toBeUpdated,
},
values: {
installed: true,
},
});
}
async enable(name: string | string[]) {
const pluginNames = _.castArray(name);
this.app.log.debug(`enabling plugin ${pluginNames.join(',')}`);
this.app.setMaintainingMessage(`enabling plugin ${pluginNames.join(',')}`);
const toBeUpdated = [];
for (const pluginName of pluginNames) {
const plugin = this.get(pluginName);
if (!plugin) {
throw new Error(`${pluginName} plugin does not exist`);
}
if (plugin.enabled) {
continue;
}
await this.app.emitAsync('beforeEnablePlugin', pluginName);
await plugin.beforeEnable();
plugin.enabled = true;
toBeUpdated.push(pluginName);
}
if (toBeUpdated.length === 0) {
return;
}
await this.repository.update({
filter: {
name: toBeUpdated,
},
values: {
enabled: true,
},
});
try {
await this.app.reload();
this.app.log.debug(`syncing database in enable plugin ${pluginNames.join(',')}...`);
this.app.setMaintainingMessage(`syncing database in enable plugin ${pluginNames.join(',')}...`);
await this.app.db.sync();
for (const pluginName of pluginNames) {
const plugin = this.get(pluginName);
if (!plugin.installed) {
this.app.log.debug(`installing plugin ${pluginName}...`);
this.app.setMaintainingMessage(`installing plugin ${pluginName}...`);
await plugin.install();
plugin.installed = true;
}
}
await this.repository.update({
filter: {
name: toBeUpdated,
},
values: {
installed: true,
},
});
for (const pluginName of pluginNames) {
const plugin = this.get(pluginName);
this.app.log.debug(`emit afterEnablePlugin event...`);
await plugin.afterEnable();
await this.app.emitAsync('afterEnablePlugin', pluginName);
this.app.log.debug(`afterEnablePlugin event emitted`);
}
await this.app.tryReloadOrRestart();
} catch (error) {
await this.repository.update({
filter: {
name: toBeUpdated,
},
values: {
enabled: false,
installed: false,
},
});
await this.app.tryReloadOrRestart();
throw error;
}
}
async disable(name: string | string[]) {
const pluginNames = _.castArray(name);
this.app.log.debug(`disabling plugin ${pluginNames.join(',')}`);
this.app.setMaintainingMessage(`disabling plugin ${pluginNames.join(',')}`);
const toBeUpdated = [];
for (const pluginName of pluginNames) {
const plugin = this.get(pluginName);
if (!plugin) {
throw new Error(`${pluginName} plugin does not exist`);
}
if (!plugin.enabled) {
continue;
}
await this.app.emitAsync('beforeDisablePlugin', pluginName);
await plugin.beforeDisable();
plugin.enabled = false;
toBeUpdated.push(pluginName);
}
if (toBeUpdated.length === 0) {
return;
}
await this.repository.update({
filter: {
name: toBeUpdated,
},
values: {
enabled: false,
},
});
try {
await this.app.tryReloadOrRestart();
for (const pluginName of pluginNames) {
const plugin = this.get(pluginName);
this.app.log.debug(`emit afterDisablePlugin event...`);
await plugin.afterDisable();
await this.app.emitAsync('afterDisablePlugin', pluginName);
this.app.log.debug(`afterDisablePlugin event emitted`);
}
} catch (error) {
await this.repository.update({
filter: {
name: toBeUpdated,
},
values: {
enabled: true,
},
});
await this.app.tryReloadOrRestart();
throw error;
}
}
async remove(name: string | string[]) {
const pluginNames = _.castArray(name);
for (const pluginName of pluginNames) {
const plugin = this.get(pluginName);
if (!plugin) {
throw new Error(`${pluginName} plugin does not exist`);
}
if (plugin.enabled) {
throw new Error(`${pluginName} plugin is enabled`);
}
await plugin.beforeRemove();
}
await this.repository.destroy({
filter: {
name: pluginNames,
},
});
const plugins: Plugin[] = [];
for (const pluginName of pluginNames) {
const plugin = this.get(pluginName);
plugins.push(plugin);
this.del(pluginName);
}
await this.app.reload();
for (const plugin of plugins) {
await plugin.afterRemove();
}
}
protected async initPresetPlugins() {
for (const plugin of this.options.plugins) {
const [p, opts = {}] = Array.isArray(plugin) ? plugin : [plugin];
await this.add(p, { enabled: true, isPreset: true, ...opts });
}
}
}

View File

@ -1,3 +1,4 @@
import { Model } from '@nocobase/database';
import { Application } from './application';
import { InstallOptions } from './plugin-manager';
@ -22,24 +23,29 @@ export interface PluginOptions {
[key: string]: any;
}
export type PluginType = typeof Plugin;
export abstract class Plugin<O = any> implements PluginInterface {
options: any;
app: Application;
model: Model;
state: any = {};
constructor(app: Application, options?: any) {
this.setOptions(options);
this.app = app;
this.setOptions(options);
this.afterAdd();
}
get log() {
return this.app.log;
}
get name() {
return this.options.name as string;
}
get pm() {
return this.app.pm;
}
get db() {
return this.app.db;
}
@ -52,6 +58,14 @@ export abstract class Plugin<O = any> implements PluginInterface {
this.options.enabled = value;
}
get installed() {
return this.options.installed;
}
set installed(value) {
this.options.installed = value;
}
setOptions(options: any) {
this.options = options || {};
}
@ -72,9 +86,13 @@ export abstract class Plugin<O = any> implements PluginInterface {
async afterEnable() {}
async beforeDisable() {}
async afterDisable() {}
async remove() {}
async beforeRemove() {}
async afterRemove() {}
async importCollections(collectionsPath: string) {
await this.db.import({

View File

@ -12,7 +12,8 @@
"pg": "^8.7.3",
"pg-hstore": "^2.3.4",
"sqlite3": "^5.0.8",
"supertest": "^6.1.6"
"supertest": "^6.1.6",
"ws": "^8.13.0"
},
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
}

View File

@ -1,5 +1,8 @@
import ws from 'ws';
export { mockDatabase } from '@nocobase/database';
export * from './mockServer';
export { default as supertest } from 'supertest';
export const pgOnly: () => jest.Describe = () => (process.env.DB_DIALECT == 'postgres' ? describe : describe.skip);
@ -7,3 +10,54 @@ export function randomStr() {
// create random string
return Math.random().toString(36).substring(2);
}
export const waitSecond = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
};
export const startServerWithRandomPort = async (startServer) => {
return await new Promise((resolve) => {
startServer({
port: 0,
host: 'localhost',
callback(server) {
// @ts-ignore
const port = server.address().port;
resolve(port);
},
});
});
};
export const createWsClient = async ({ serverPort, options = {} }) => {
console.log(`connect to ws://localhost:${serverPort}/ws`, options);
const wsc = new ws(`ws://localhost:${serverPort}/ws`, options);
const messages = [];
wsc.on('message', (data) => {
const message = data.toString();
messages.push(message);
});
// await connection established
await new Promise((resolve) => {
wsc.on('open', resolve);
});
return {
wsc,
messages,
async stop() {
const promise = new Promise((resolve) => {
wsc.on('close', resolve);
});
wsc.close();
await promise;
},
lastMessage() {
return JSON.parse(messages[messages.length - 1]);
},
};
};

View File

@ -1,5 +1,5 @@
import { Database, mockDatabase } from '@nocobase/database';
import Application, { ApplicationOptions, PluginManager } from '@nocobase/server';
import Application, { AppSupervisor, ApplicationOptions, Gateway, PluginManager } from '@nocobase/server';
import jwt from 'jsonwebtoken';
import qs from 'qs';
import supertest, { SuperAgentTest } from 'supertest';
@ -81,12 +81,19 @@ export class MockServer extends Application {
await this.db.clean({ drop: true });
}
async destroy(options: any = {}): Promise<void> {
await super.destroy(options);
Gateway.getInstance().destroy();
await AppSupervisor.getInstance().destroy();
}
agent(): SuperAgentTest & {
login: (user: any) => SuperAgentTest;
loginUsingId: (userId: number) => SuperAgentTest;
resource: (name: string, resourceOf?: any) => Resource;
} {
const agent = supertest.agent(this.appManager.callback());
const agent = supertest.agent(this.callback());
const prefix = this.resourcer.options.prefix;
const proxy = new Proxy(agent, {
get(target, method: string, receiver) {
@ -116,7 +123,8 @@ export class MockServer extends Application {
{
get(target, method: string, receiver) {
return (params: ActionParams = {}) => {
let { filterByTk, values = {}, file, ...restParams } = params;
let { filterByTk } = params;
const { values = {}, file, ...restParams } = params;
if (params.associatedIndex) {
resourceOf = params.associatedIndex;
}
@ -172,6 +180,9 @@ export function mockServer(options: ApplicationOptions = {}) {
global.TextDecoder = require('util').TextDecoder;
}
Gateway.getInstance().reset();
AppSupervisor.getInstance().reset();
// @ts-ignore
if (!PluginManager.findPackagePatched) {
PluginManager.getPackageJson = () => {

View File

@ -13,8 +13,6 @@ export async function prepareApp(): Promise<MockServer> {
});
await app.loadAndInstall({ clean: true });
await app.db.sync();
await app.start();
return app;
}

View File

@ -31,7 +31,7 @@ describe('actions', () => {
});
afterEach(async () => {
await db.close();
await app.destroy();
});
it('update profile with roles', async () => {

View File

@ -342,23 +342,17 @@ export class PluginACL extends Plugin {
}
});
// sync database role data to acl
this.app.on('afterLoad', async (app, options) => {
if (options?.method === 'install' || options?.method === 'upgrade') {
return;
}
const writeRolesToACL = async (app, options) => {
const exists = await this.app.db.collectionExistsInDb('roles');
if (exists) {
this.log.info('write roles to ACL');
await this.writeRolesToACL();
}
});
};
this.app.on('afterInstall', async (app, options) => {
const exists = await this.app.db.collectionExistsInDb('roles');
if (exists) {
await this.writeRolesToACL();
}
});
// sync database role data to acl
this.app.on('afterLoad', writeRolesToACL);
this.app.on('afterInstall', writeRolesToACL);
this.app.on('afterInstallPlugin', async (plugin) => {
if (plugin.getName() !== 'users') {

View File

@ -26,7 +26,7 @@ describe('actions', () => {
await repo.destroy({
truncate: true,
});
await db.close();
await app.destroy();
});
let user;
@ -54,7 +54,7 @@ describe('actions', () => {
},
});
role = await (db.getRepository('users.roles', user.id) as unknown as Repository).findOne({
role = await ((db.getRepository('users.roles', user.id) as unknown) as Repository).findOne({
where: {
default: true,
},

View File

@ -8,9 +8,8 @@ describe('hook', () => {
beforeEach(async () => {
api = mockServer();
await api.db.clean({ drop: true });
api.plugin(logPlugin, { name: 'audit-logs' });
await api.load();
await api.loadAndInstall({ clean: true });
db = api.db;
db.collection({
name: 'posts',

View File

@ -27,7 +27,7 @@ describe('actions', () => {
});
afterAll(async () => {
await db.close();
await app.destroy();
});
it('should list authenticator types', async () => {

View File

@ -19,7 +19,7 @@ describe('token-blacklist', () => {
});
afterAll(async () => {
await db.close();
await app.destroy();
});
afterEach(async () => {
@ -66,7 +66,6 @@ describe('token-blacklist', () => {
token: 'should not be deleted',
expiration: new Date('2100-01-01'),
});
await tokenBlacklist.deleteByExpiration();
expect(await tokenBlacklist.has('should be deleted')).not.toBeTruthy();
expect(await tokenBlacklist.has('should not be deleted')).toBeTruthy();
});

View File

@ -9,36 +9,12 @@ export class TokenBlacklistService implements ITokenBlacklistService {
constructor(protected plugin: AuthPlugin) {
this.repo = plugin.db.getRepository('tokenBlacklist');
this.cronJob = this.createCronJob();
}
get app() {
return this.plugin.app;
}
createCronJob() {
const cronJob = new CronJob(
// every day at 03:00
'0 3 * * *', //
async () => {
this.app.logger.info(`${this.plugin.name}: Start delete expired blacklist token`);
await this.deleteByExpiration();
this.app.logger.info(`${this.plugin.name}: End delete expired blacklist token`);
},
null,
);
this.app.once('beforeStart', () => {
cronJob.start();
});
this.app.once('beforeStop', () => {
cronJob.stop();
});
return cronJob;
}
async has(token: string) {
return !!(await this.repo.findOne({
filter: {
@ -46,7 +22,9 @@ export class TokenBlacklistService implements ITokenBlacklistService {
},
}));
}
async add(values) {
await this.deleteExpiredTokens();
return this.repo.model.findOrCreate({
defaults: values,
where: {
@ -54,7 +32,8 @@ export class TokenBlacklistService implements ITokenBlacklistService {
},
});
}
async deleteByExpiration() {
async deleteExpiredTokens() {
return this.repo.destroy({
filter: {
expiration: {

Some files were not shown because too many files have changed in this diff Show More