diff --git a/packages/database/src/__tests__/database.test.ts b/packages/database/src/__tests__/database.test.ts index dce2fc2d8..71adbf24b 100644 --- a/packages/database/src/__tests__/database.test.ts +++ b/packages/database/src/__tests__/database.test.ts @@ -14,6 +14,9 @@ describe('database', () => { }); test('close state', async () => { + if (db.isSqliteMemory()) { + return; + } expect(db.closed()).toBeFalsy(); await db.close(); expect(db.closed()).toBeTruthy(); diff --git a/packages/database/src/collection.ts b/packages/database/src/collection.ts index 76f6b570e..7c9033080 100644 --- a/packages/database/src/collection.ts +++ b/packages/database/src/collection.ts @@ -11,6 +11,7 @@ export type RepositoryType = typeof Repository; export type CollectionSortable = string | boolean | { name?: string; scopeKey?: string }; + export interface CollectionOptions extends Omit { name: string; tableName?: string; diff --git a/packages/database/src/database.ts b/packages/database/src/database.ts index 3a024e95c..37964c31e 100644 --- a/packages/database/src/database.ts +++ b/packages/database/src/database.ts @@ -1,15 +1,7 @@ import { applyMixins, AsyncEmitter } from '@nocobase/utils'; import merge from 'deepmerge'; import { EventEmitter } from 'events'; -import { - ModelCtor, - Op, - Options, - QueryInterfaceDropAllTablesOptions, - Sequelize, - SyncOptions, - Utils -} from 'sequelize'; +import { ModelCtor, Op, Options, QueryInterfaceDropAllTablesOptions, Sequelize, SyncOptions, Utils } from 'sequelize'; import { Collection, CollectionOptions, RepositoryType } from './collection'; import { ImporterReader, ImportFileExtension } from './collection-importer'; import * as FieldTypes from './fields'; @@ -19,6 +11,7 @@ import { ModelHook } from './model-hook'; import extendOperators from './operators'; import { RelationRepository } from './relation-repository/relation-repository'; import { Repository } from './repository'; +import lodash from 'lodash'; export interface MergeOptions extends merge.Options {} @@ -250,7 +243,14 @@ export class Database extends EventEmitter implements AsyncEmitter { } } + public isSqliteMemory() { + return this.sequelize.getDialect() === 'sqlite' && lodash.get(this.options, 'storage') == ':memory:'; + } + async reconnect() { + if (this.isSqliteMemory()) { + return; + } // @ts-ignore const ConnectionManager = this.sequelize.dialect.connectionManager.constructor; // @ts-ignore @@ -267,6 +267,10 @@ export class Database extends EventEmitter implements AsyncEmitter { } async close() { + if (this.isSqliteMemory()) { + return; + } + return this.sequelize.close(); } diff --git a/packages/database/src/mock-database.ts b/packages/database/src/mock-database.ts index c7826c842..c7af93331 100644 --- a/packages/database/src/mock-database.ts +++ b/packages/database/src/mock-database.ts @@ -7,6 +7,7 @@ export class MockDatabase extends Database { super({ storage: ':memory:', tablePrefix: `mock_${uid(6)}_`, + dialect: 'sqlite', ...options, }); this.sequelize.beforeDefine((model, opts) => { @@ -24,7 +25,10 @@ export function getConfigByEnv() { port: process.env.DB_PORT, dialect: process.env.DB_DIALECT, logging: process.env.DB_LOG_SQL === 'on' ? console.log : false, - storage: process.env.DB_STORAGE && process.env.DB_STORAGE !== ':memory:' ? resolve(process.cwd(), process.env.DB_STORAGE) : ':memory:', + storage: + process.env.DB_STORAGE && process.env.DB_STORAGE !== ':memory:' + ? resolve(process.cwd(), process.env.DB_STORAGE) + : ':memory:', define: { charset: 'utf8mb4', collate: 'utf8mb4_unicode_ci', @@ -33,5 +37,6 @@ export function getConfigByEnv() { } export function mockDatabase(options: IDatabaseOptions = {}): MockDatabase { - return new MockDatabase(merge(getConfigByEnv(), options)); + const dbOptions = merge(getConfigByEnv(), options); + return new MockDatabase(dbOptions); } diff --git a/packages/plugin-acl/src/server.ts b/packages/plugin-acl/src/server.ts index 8bf61ff84..82cc55187 100644 --- a/packages/plugin-acl/src/server.ts +++ b/packages/plugin-acl/src/server.ts @@ -278,6 +278,10 @@ export class PluginACL extends Plugin { this.app.resourcer.use(this.acl.middleware()); } + + getName(): string { + return this.getPackageName(__dirname); + } } export default PluginACL; diff --git a/packages/plugin-action-logs/src/server.ts b/packages/plugin-action-logs/src/server.ts index a61c4cd64..3df4a5984 100644 --- a/packages/plugin-action-logs/src/server.ts +++ b/packages/plugin-action-logs/src/server.ts @@ -14,4 +14,8 @@ export default class PluginActionLogs extends Plugin { directory: path.resolve(__dirname, 'collections'), }); } + + getName(): string { + return this.getPackageName(__dirname); + } } diff --git a/packages/plugin-china-region/src/server.ts b/packages/plugin-china-region/src/server.ts index 80e817e60..c939daf8b 100644 --- a/packages/plugin-china-region/src/server.ts +++ b/packages/plugin-china-region/src/server.ts @@ -62,6 +62,10 @@ export class ChinaRegionPlugin extends Plugin { const count = await ChinaRegion.count(); console.log(`${count} rows of region data imported in ${(Date.now() - timer) / 1000}s`); } + + getName(): string { + return this.getPackageName(__dirname); + } } export default ChinaRegionPlugin; diff --git a/packages/plugin-client/src/plugin.ts b/packages/plugin-client/src/plugin.ts index 1cee9358f..ea0eafcf3 100644 --- a/packages/plugin-client/src/plugin.ts +++ b/packages/plugin-client/src/plugin.ts @@ -60,6 +60,10 @@ export class ClientPlugin extends Plugin { } }); } + + getName(): string { + return this.getPackageName(__dirname); + } } export default ClientPlugin; diff --git a/packages/plugin-collection-manager/src/plugin.ts b/packages/plugin-collection-manager/src/plugin.ts index 06b5fc42c..2b98f38f9 100644 --- a/packages/plugin-collection-manager/src/plugin.ts +++ b/packages/plugin-collection-manager/src/plugin.ts @@ -86,6 +86,10 @@ export class CollectionManagerPlugin extends Plugin { directory: path.resolve(__dirname, './collections'), }); } + + getName(): string { + return this.getPackageName(__dirname); + } } export default CollectionManagerPlugin; diff --git a/packages/plugin-error-handler/src/server.ts b/packages/plugin-error-handler/src/server.ts index 3bc111595..d11f68c38 100644 --- a/packages/plugin-error-handler/src/server.ts +++ b/packages/plugin-error-handler/src/server.ts @@ -7,6 +7,10 @@ import enUS from './locale/en_US'; import zhCN from './locale/zh_CN'; export class PluginErrorHandler extends Plugin { + getName(): string { + return this.getPackageName(__dirname); + } + errorHandler: ErrorHandler = new ErrorHandler(); i18nNs: string = 'error-handler'; diff --git a/packages/plugin-file-manager/src/server.ts b/packages/plugin-file-manager/src/server.ts index 35e8fc6aa..887c75493 100644 --- a/packages/plugin-file-manager/src/server.ts +++ b/packages/plugin-file-manager/src/server.ts @@ -36,4 +36,8 @@ export default class PluginFileManager extends Plugin { await getStorageConfig(STORAGE_TYPE_LOCAL).middleware(this.app); } } + + getName(): string { + return this.getPackageName(__dirname); + } } diff --git a/packages/plugin-multiple-apps/.npmignore b/packages/plugin-multiple-apps/.npmignore new file mode 100644 index 000000000..461574b2f --- /dev/null +++ b/packages/plugin-multiple-apps/.npmignore @@ -0,0 +1,7 @@ +node_modules +*.log +docs +__tests__ +tsconfig.json +src +.fatherrc.ts \ No newline at end of file diff --git a/packages/plugin-multiple-apps/package.json b/packages/plugin-multiple-apps/package.json new file mode 100644 index 000000000..67324f370 --- /dev/null +++ b/packages/plugin-multiple-apps/package.json @@ -0,0 +1,16 @@ +{ + "name": "@nocobase/plugin-multiple-apps", + "version": "0.6.0-alpha.0", + "main": "lib/index.js", + "license": "MIT", + "scripts": { + "build": "rimraf -rf lib esm dist && npm run build:cjs && npm run build:esm", + "build:cjs": "tsc --project tsconfig.build.json", + "build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir esm" + }, + "dependencies": { + "@nocobase/server": "^0.6.0-alpha.0" + }, + "devDependencies": { + } +} diff --git a/packages/plugin-multiple-apps/src/__tests__/mock-get-schema.test.ts b/packages/plugin-multiple-apps/src/__tests__/mock-get-schema.test.ts new file mode 100644 index 000000000..dc2015c6d --- /dev/null +++ b/packages/plugin-multiple-apps/src/__tests__/mock-get-schema.test.ts @@ -0,0 +1,135 @@ +import { Plugin } from '@nocobase/server'; +import { ApplicationModel } from '../models/application'; +import { mockServer } from '@nocobase/test'; +import { PluginMultipleApps } from '../server'; + +describe('test with start', () => { + it('should load subApp on create', async () => { + const loadFn = jest.fn(); + const installFn = jest.fn(); + + class TestPlugin extends Plugin { + getName(): string { + return 'test-package'; + } + + async load(): Promise { + loadFn(); + } + + async install() { + installFn(); + } + } + + const mockGetPluginByName = jest.fn(); + mockGetPluginByName.mockReturnValue(TestPlugin); + ApplicationModel.getPluginByName = mockGetPluginByName; + + const app = mockServer(); + await app.cleanDb(); + app.plugin(PluginMultipleApps); + + await app.loadAndInstall(); + await app.start(); + + const db = app.db; + + await db.getRepository('applications').create({ + values: { + name: 'sub1', + plugins: [ + { + name: 'test-package', + }, + ], + }, + }); + + expect(loadFn).toHaveBeenCalledTimes(1); + expect(installFn).toHaveBeenCalledTimes(1); + + await app.destroy(); + }); + + it('should install into difference database', async () => { + const app = mockServer(); + await app.cleanDb(); + app.plugin(PluginMultipleApps); + + await app.loadAndInstall(); + await app.start(); + + const db = app.db; + + await db.getRepository('applications').create({ + values: { + name: 'sub1', + plugins: [ + { + name: '@nocobase/plugin-ui-schema-storage', + }, + ], + }, + }); + await app.destroy(); + }); + + it('should lazy load applications', async () => { + class TestPlugin extends Plugin { + getName(): string { + return 'test-package'; + } + } + + let app = mockServer(); + await app.cleanDb(); + app.plugin(PluginMultipleApps); + + await app.loadAndInstall(); + await app.start(); + + const db = app.db; + + const mockGetPluginByName = jest.fn(); + mockGetPluginByName.mockReturnValue(TestPlugin); + ApplicationModel.getPluginByName = mockGetPluginByName; + + await db.getRepository('applications').create({ + values: { + name: 'sub1', + plugins: [ + { + name: 'test-package', + }, + ], + }, + }); + + expect(app.appManager.applications.get('sub1')).toBeDefined(); + + await app.stop(); + + let newApp = mockServer({ + database: app.db, + }); + + newApp.plugin(PluginMultipleApps); + await newApp.db.reconnect(); + + await newApp.load(); + await newApp.start(); + + expect(await newApp.db.getRepository('applications').count()).toEqual(1); + expect(newApp.appManager.applications.get('sub1')).not.toBeDefined(); + + newApp.appManager.setAppSelector(() => { + return 'sub1'; + }); + + await newApp.agent().resource('test').test(); + expect(newApp.appManager.applications.get('sub1')).toBeDefined(); + + await app.destroy(); + }); +}); diff --git a/packages/plugin-multiple-apps/src/__tests__/multiple-apps.test.ts b/packages/plugin-multiple-apps/src/__tests__/multiple-apps.test.ts new file mode 100644 index 000000000..790a46989 --- /dev/null +++ b/packages/plugin-multiple-apps/src/__tests__/multiple-apps.test.ts @@ -0,0 +1,92 @@ +import { mockServer, MockServer } from '@nocobase/test'; +import { Database } from '@nocobase/database'; +import { PluginMultipleApps } from '../server'; + +describe('multiple apps create', () => { + let app: MockServer; + let db: Database; + + beforeEach(async () => { + app = mockServer({}); + db = app.db; + await app.cleanDb(); + app.plugin(PluginMultipleApps); + + await app.loadAndInstall(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should create application', async () => { + const miniApp = await db.getRepository('applications').create({ + values: { + name: 'miniApp', + }, + }); + + expect(app.appManager.applications.get('miniApp')).toBeDefined(); + }); + + it('should remove application', async () => { + await db.getRepository('applications').create({ + values: { + name: 'miniApp', + }, + }); + + expect(app.appManager.applications.get('miniApp')).toBeDefined(); + + await db.getRepository('applications').destroy({ + filter: { + name: 'miniApp', + }, + }); + + expect(app.appManager.applications.get('miniApp')).toBeUndefined(); + }); + + it('should create with plugins', async () => { + await db.getRepository('applications').create({ + values: { + name: 'miniApp', + plugins: [ + { + name: '@nocobase/plugin-ui-schema-storage', + }, + ], + }, + }); + + const miniApp = app.appManager.applications.get('miniApp'); + expect(miniApp).toBeDefined(); + + expect(miniApp.pm.get('@nocobase/plugin-ui-schema-storage')).toBeDefined(); + }); + + it('should lazy load applications', async () => { + await db.getRepository('applications').create({ + values: { + name: 'miniApp', + plugins: [ + { + name: '@nocobase/plugin-ui-schema-storage', + }, + ], + }, + }); + + await app.appManager.removeApplication('miniApp'); + + app.appManager.setAppSelector(() => { + return 'miniApp'; + }); + + expect(app.appManager.applications.has('miniApp')).toBeFalsy(); + + await app.agent().resource('test').test(); + + expect(app.appManager.applications.has('miniApp')).toBeTruthy(); + }); +}); diff --git a/packages/plugin-multiple-apps/src/collections/application-plugins.ts b/packages/plugin-multiple-apps/src/collections/application-plugins.ts new file mode 100644 index 000000000..8dff18c56 --- /dev/null +++ b/packages/plugin-multiple-apps/src/collections/application-plugins.ts @@ -0,0 +1,17 @@ +import { defineCollection } from '@nocobase/database'; + +export default defineCollection({ + name: 'applicationPlugins', + timestamps: false, + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'belongsTo', + name: 'application', + foreignKey: 'applicationName', + }, + ], +}); diff --git a/packages/plugin-multiple-apps/src/collections/applications.ts b/packages/plugin-multiple-apps/src/collections/applications.ts new file mode 100644 index 000000000..82800773c --- /dev/null +++ b/packages/plugin-multiple-apps/src/collections/applications.ts @@ -0,0 +1,24 @@ +import { defineCollection } from '@nocobase/database'; + +export default defineCollection({ + name: 'applications', + model: 'ApplicationModel', + autoGenId: false, + fields: [ + { + type: 'string', + name: 'name', + primaryKey: true, + }, + { + type: 'json', + name: 'options', + }, + { + type: 'hasMany', + name: 'plugins', + target: 'applicationPlugins', + foreignKey: 'applicationName', + }, + ], +}); diff --git a/packages/plugin-multiple-apps/src/models/application.ts b/packages/plugin-multiple-apps/src/models/application.ts new file mode 100644 index 000000000..73f9f65da --- /dev/null +++ b/packages/plugin-multiple-apps/src/models/application.ts @@ -0,0 +1,92 @@ +import Database, { IDatabaseOptions, Model, TransactionAble } from '@nocobase/database'; +import { Application } from '@nocobase/server'; +import lodash from 'lodash'; +import * as path from 'path'; + +interface registerAppOptions extends TransactionAble { + skipInstall?: boolean; +} + +export class ApplicationModel extends Model { + static getPluginByName(pluginName: string) { + return require(pluginName).default; + } + + static getDatabaseConfig(app: Application): IDatabaseOptions { + return lodash.cloneDeep( + lodash.isPlainObject(app.options.database) + ? (app.options.database as IDatabaseOptions) + : (app.options.database as Database).options, + ); + } + + async registerToMainApp(mainApp: Application, options: registerAppOptions) { + const { transaction } = options; + const appName = this.get('name') as string; + const app = mainApp.appManager.createApplication(appName, ApplicationModel.initOptions(appName, mainApp)); + + // @ts-ignore + const plugins = await this.getPlugins({ transaction }); + + for (const pluginInstance of plugins) { + const plugin = ApplicationModel.getPluginByName(pluginInstance.get('name') as string); + app.plugin(plugin); + } + + app.on('beforeInstall', async function createDatabase() { + const { host, port, username, password, database, dialect } = ApplicationModel.getDatabaseConfig(app); + + if (dialect === 'mysql') { + const mysql = require('mysql2/promise'); + const connection = await mysql.createConnection({ host, port, user: username, password }); + await connection.query(`CREATE DATABASE IF NOT EXISTS \`${database}\`;`); + await connection.close(); + } + + if (dialect === 'postgres') { + const { Client } = require('pg'); + + const client = new Client({ + user: username, + host, + password: password, + port, + }); + + await client.connect(); + + try { + await client.query(`CREATE DATABASE "${database}"`); + } catch (e) {} + + await client.end(); + } + }); + + await app.load(); + + if (!lodash.get(options, 'skipInstall', false)) { + await app.install(); + } + + await app.start(); + } + + static initOptions(appName: string, mainApp: Application) { + const rawDatabaseOptions = ApplicationModel.getDatabaseConfig(mainApp); + + if (rawDatabaseOptions.dialect === 'sqlite') { + const mainAppStorage = rawDatabaseOptions.storage; + if (mainAppStorage !== ':memory:') { + const mainStorageDir = path.dirname(mainAppStorage); + rawDatabaseOptions.storage = path.join(mainStorageDir, `${appName}.sqlite`); + } + } else { + rawDatabaseOptions.database = appName; + } + + return { + database: rawDatabaseOptions, + }; + } +} diff --git a/packages/plugin-multiple-apps/src/server.ts b/packages/plugin-multiple-apps/src/server.ts new file mode 100644 index 000000000..ecc711d01 --- /dev/null +++ b/packages/plugin-multiple-apps/src/server.ts @@ -0,0 +1,46 @@ +import { Plugin } from '@nocobase/server'; +import { resolve } from 'path'; +import { ApplicationModel } from './models/application'; +import { AppManager } from '@nocobase/server'; + +export class PluginMultipleApps extends Plugin { + getName(): string { + return this.getPackageName(__dirname); + } + + async load() { + this.db.registerModels({ + ApplicationModel, + }); + + await this.db.import({ + directory: resolve(__dirname, 'collections'), + }); + + this.db.on('applications.afterCreateWithAssociations', async (model: ApplicationModel, options) => { + const { transaction } = options; + await model.registerToMainApp(this.app, { transaction }); + }); + + this.db.on('applications.afterDestroy', async (model: ApplicationModel) => { + await this.app.appManager.removeApplication(model.get('name') as string); + }); + + this.app.appManager.on( + 'beforeGetApplication', + async function lazyLoadApplication({ appManager, name }: { appManager: AppManager; name: string }) { + if (!appManager.applications.has(name)) { + const existsApplication = (await this.app.db.getRepository('applications').findOne({ + filter: { + name, + }, + })) as ApplicationModel | null; + + if (existsApplication) { + await existsApplication.registerToMainApp(this.app, { skipInstall: true }); + } + } + }, + ); + } +} diff --git a/packages/plugin-multiple-apps/tsconfig.build.json b/packages/plugin-multiple-apps/tsconfig.build.json new file mode 100644 index 000000000..8d3db734b --- /dev/null +++ b/packages/plugin-multiple-apps/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./lib", + "declaration": true + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./src/__tests__/*", "./esm/*", "./lib/*"] +} \ No newline at end of file diff --git a/packages/plugin-multiple-apps/tsconfig.json b/packages/plugin-multiple-apps/tsconfig.json new file mode 100644 index 000000000..e10a5305a --- /dev/null +++ b/packages/plugin-multiple-apps/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./esm/*", "./lib/*"] +} \ No newline at end of file diff --git a/packages/plugin-notifications/src/server.ts b/packages/plugin-notifications/src/server.ts index faf4665f2..e6e3d9cb5 100644 --- a/packages/plugin-notifications/src/server.ts +++ b/packages/plugin-notifications/src/server.ts @@ -7,4 +7,8 @@ export default class PluginNotifications extends Plugin { directory: path.resolve(__dirname, 'collections'), }); } + + getName(): string { + return this.getPackageName(__dirname); + } } diff --git a/packages/plugin-system-settings/src/plugin.ts b/packages/plugin-system-settings/src/plugin.ts index 559dad368..c0f43a384 100644 --- a/packages/plugin-system-settings/src/plugin.ts +++ b/packages/plugin-system-settings/src/plugin.ts @@ -29,6 +29,10 @@ export class SystemSettingsPlugin extends Plugin { }), ); } + + getName(): string { + return this.getPackageName(__dirname); + } } export default SystemSettingsPlugin; diff --git a/packages/plugin-ui-routes-storage/src/index.ts b/packages/plugin-ui-routes-storage/src/index.ts index 714cc9103..94e6fc395 100644 --- a/packages/plugin-ui-routes-storage/src/index.ts +++ b/packages/plugin-ui-routes-storage/src/index.ts @@ -5,6 +5,10 @@ import { resolve } from 'path'; import { getAccessible } from './actions/getAccessible'; export class UiRoutesStoragePlugin extends Plugin { + getName(): string { + return this.getPackageName(__dirname); + } + async install() { const repository = this.app.db.getRepository('uiRoutes'); const routes = [ diff --git a/packages/plugin-ui-schema-storage/src/__tests__/server-hook-impl.test.ts b/packages/plugin-ui-schema-storage/src/__tests__/server-hook-impl.test.ts index 102c23139..48a723a2a 100644 --- a/packages/plugin-ui-schema-storage/src/__tests__/server-hook-impl.test.ts +++ b/packages/plugin-ui-schema-storage/src/__tests__/server-hook-impl.test.ts @@ -30,7 +30,7 @@ describe('server hooks', () => { uiSchemaRepository = db.getRepository('uiSchemas'); - uiSchemaPlugin = app.getPlugin('UiSchemaStoragePlugin'); + uiSchemaPlugin = app.getPlugin('@nocobase/plugin-ui-schema-storage'); }); it('should clean row struct', async () => { diff --git a/packages/plugin-ui-schema-storage/src/__tests__/server-hook.test.ts b/packages/plugin-ui-schema-storage/src/__tests__/server-hook.test.ts index 8dd6d0884..f770210a3 100644 --- a/packages/plugin-ui-schema-storage/src/__tests__/server-hook.test.ts +++ b/packages/plugin-ui-schema-storage/src/__tests__/server-hook.test.ts @@ -69,7 +69,7 @@ describe('server hooks', () => { uiSchemaRepository = db.getRepository('uiSchemas'); await uiSchemaRepository.insert(schema); - uiSchemaPlugin = app.getPlugin('UiSchemaStoragePlugin'); + uiSchemaPlugin = app.getPlugin('@nocobase/plugin-ui-schema-storage'); }); it('should call server hooks onFieldDestroy', async () => { diff --git a/packages/plugin-ui-schema-storage/src/server.ts b/packages/plugin-ui-schema-storage/src/server.ts index a9ba7dc9a..f1c7339ec 100644 --- a/packages/plugin-ui-schema-storage/src/server.ts +++ b/packages/plugin-ui-schema-storage/src/server.ts @@ -70,6 +70,10 @@ export class UiSchemaStoragePlugin extends Plugin { directory: path.resolve(__dirname, 'collections'), }); } + + getName(): string { + return this.getPackageName(__dirname); + } } export default UiSchemaStoragePlugin; diff --git a/packages/plugin-users/src/server.ts b/packages/plugin-users/src/server.ts index 72b93203b..3e014f38b 100644 --- a/packages/plugin-users/src/server.ts +++ b/packages/plugin-users/src/server.ts @@ -107,4 +107,8 @@ export default class UsersPlugin extends Plugin { await repo.db2cm('users'); } } + + getName(): string { + return this.getPackageName(__dirname); + } } diff --git a/packages/plugin-workflow/src/server.ts b/packages/plugin-workflow/src/server.ts index bfbe686e2..ddc95bdec 100644 --- a/packages/plugin-workflow/src/server.ts +++ b/packages/plugin-workflow/src/server.ts @@ -28,12 +28,10 @@ export default class WorkflowPlugin extends Plugin { this.app.on('beforeStart', async () => { const { model } = db.getCollection('workflows'); await (model as typeof WorkflowModel).mount(); - }) + }); // [Life Cycle]: initialize all necessary seed data - this.app.on('db.init', async () => { - - }); + this.app.on('db.init', async () => {}); // const [Automation, AutomationJob] = database.getModels(['automations', 'automations_jobs']); @@ -60,4 +58,8 @@ export default class WorkflowPlugin extends Plugin { // await model.cancel(); // }); } + + getName(): string { + return this.getPackageName(__dirname); + } } diff --git a/packages/server/package.json b/packages/server/package.json index 6bd120c7f..9e2f9d1ad 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -18,6 +18,7 @@ "@nocobase/resourcer": "^0.6.0-alpha.0", "commander": "^8.1.0", "dotenv": "^8.2.0", + "find-package-json": "^1.2.0", "i18next": "^21.3.2", "koa": "^2.13.4", "koa-bodyparser": "^4.3.0", diff --git a/packages/server/src/__tests__/multiple-application.test.ts b/packages/server/src/__tests__/multiple-application.test.ts new file mode 100644 index 000000000..49d546b1a --- /dev/null +++ b/packages/server/src/__tests__/multiple-application.test.ts @@ -0,0 +1,103 @@ +import { mockServer, MockServer } from '@nocobase/test'; +import { IncomingMessage } from 'http'; +import * as url from 'url'; + +describe('multiple apps', () => { + it('should emit beforeGetApplication event', async () => { + const beforeGetApplicationFn = jest.fn(); + + const app = mockServer(); + + app.appManager.on('beforeGetApplication', beforeGetApplicationFn); + + app.appManager.createApplication('sub1', { + database: app.db, + }); + + 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.createApplication('sub1', { + database: app.db, + }); + + const subApp1StopFn = jest.fn(); + + subApp1.on('afterStop', subApp1StopFn); + + await app.stop(); + + expect(subApp1StopFn).toBeCalledTimes(1); + + await app.destroy(); + }); +}); + +describe('multiple application', () => { + let app: MockServer; + beforeEach(async () => { + app = mockServer(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should create multiple apps', async () => { + const subApp1 = app.appManager.createApplication('sub1', { + database: app.db, + }); + + subApp1.resourcer.define({ + name: 'test', + actions: { + async test(ctx) { + ctx.body = 'sub1'; + }, + }, + }); + + const subApp2 = app.appManager.createApplication('sub2', { + database: app.db, + }); + + subApp2.resourcer.define({ + name: 'test', + actions: { + async test(ctx) { + ctx.body = 'sub2'; + }, + }, + }); + + 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); + }); +}); diff --git a/packages/server/src/__tests__/plugin.test.ts b/packages/server/src/__tests__/plugin.test.ts index 47750c41b..effcf160d 100644 --- a/packages/server/src/__tests__/plugin.test.ts +++ b/packages/server/src/__tests__/plugin.test.ts @@ -18,7 +18,11 @@ describe('plugin', () => { describe('define', () => { it('should add plugin with options', async () => { - class MyPlugin extends Plugin {} + class MyPlugin extends Plugin { + getName(): string { + return 'test'; + } + } const plugin = app.plugin(MyPlugin, { test: 'hello', @@ -35,6 +39,10 @@ describe('plugin', () => { async load() { this.options.a; } + + getName(): string { + return 'MyPlugin'; + } } const plugin = app.plugin(MyPlugin, { a: 'aa', @@ -68,6 +76,10 @@ describe('plugin', () => { name: 'tests', }); } + + getName(): string { + return 'test'; + } }, ); await app.load(); diff --git a/packages/server/src/__tests__/plugins/plugin1.ts b/packages/server/src/__tests__/plugins/plugin1.ts index 28af21064..848a2259d 100644 --- a/packages/server/src/__tests__/plugins/plugin1.ts +++ b/packages/server/src/__tests__/plugins/plugin1.ts @@ -6,4 +6,8 @@ export default class Plugin1 extends Plugin { name: 'tests', }); } + + getName(): string { + return 'Plugin1'; + } } diff --git a/packages/server/src/__tests__/plugins/plugin2.ts b/packages/server/src/__tests__/plugins/plugin2.ts index d9f0b6730..9be40ce96 100644 --- a/packages/server/src/__tests__/plugins/plugin2.ts +++ b/packages/server/src/__tests__/plugins/plugin2.ts @@ -6,4 +6,8 @@ export default class Plugin2 extends Plugin { name: 'tests', }); } + + getName(): string { + return 'Plugin2'; + } } diff --git a/packages/server/src/__tests__/plugins/plugin3.ts b/packages/server/src/__tests__/plugins/plugin3.ts index 4d2f89fcb..b30b69951 100644 --- a/packages/server/src/__tests__/plugins/plugin3.ts +++ b/packages/server/src/__tests__/plugins/plugin3.ts @@ -6,4 +6,8 @@ export default class Plugin3 extends Plugin { name: 'tests', }); } + + getName(): string { + return 'Plugin3'; + } } diff --git a/packages/server/src/app-manager.ts b/packages/server/src/app-manager.ts new file mode 100644 index 000000000..a0e6033b9 --- /dev/null +++ b/packages/server/src/app-manager.ts @@ -0,0 +1,79 @@ +import Application, { ApplicationOptions } from './application'; +import http, { IncomingMessage } from 'http'; +import EventEmitter from 'events'; +import { applyMixins, AsyncEmitter } from '@nocobase/utils'; + +type AppSelector = (ctx) => Application | string; + +export class AppManager extends EventEmitter { + public applications: Map = new Map(); + + constructor(private app: Application) { + super(); + + app.on('beforeStop', async (mainApp, options) => { + return await Promise.all( + [...this.applications.values()].map((application: Application) => application.stop(options)), + ); + }); + + app.on('afterDestroy', async (mainApp, options) => { + return await Promise.all( + [...this.applications.values()].map((application: Application) => application.destroy(options)), + ); + }); + } + + appSelector: AppSelector = (req: IncomingMessage) => this.app; + + createApplication(name: string, options: ApplicationOptions): Application { + const application = new Application(options); + this.applications.set(name, application); + return application; + } + + async removeApplication(name: string) { + const application = this.applications.get(name); + if (!application) { + return; + } + + await application.destroy(); + + 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): Promise { + await this.emitAsync('beforeGetApplication', { + appManager: this, + name: appName, + }); + + return this.applications.get(appName); + } + + callback() { + return async (req, res) => { + let handleApp = this.appSelector(req); + + if (typeof handleApp === 'string') { + handleApp = (await this.getApplication(handleApp)) || this.app; + } + + handleApp.callback()(req, res); + }; + } + + emitAsync: (event: string | symbol, ...args: any[]) => Promise; +} + +applyMixins(AppManager, [AsyncEmitter]); diff --git a/packages/server/src/application.ts b/packages/server/src/application.ts index f357e0e21..3d7dd563b 100644 --- a/packages/server/src/application.ts +++ b/packages/server/src/application.ts @@ -1,6 +1,6 @@ import { ACL } from '@nocobase/acl'; import { registerActions } from '@nocobase/actions'; -import Database, { CleanOptions, CollectionOptions, IDatabaseOptions, SyncOptions } from '@nocobase/database'; +import Database, { CollectionOptions, IDatabaseOptions } from '@nocobase/database'; import Resourcer, { ResourceOptions } from '@nocobase/resourcer'; import { applyMixins, AsyncEmitter } from '@nocobase/utils'; import { Command, CommandOptions } from 'commander'; @@ -12,6 +12,7 @@ import { createACL } from './acl'; import { createCli, createDatabase, createI18n, createResourcer, registerMiddlewares } from './helper'; import { Plugin } from './plugin'; import { PluginManager, InstallOptions } from './plugin-manager'; +import { AppManager } from './app-manager'; export interface ResourcerOptions { prefix?: string; @@ -84,11 +85,13 @@ export class Application exten public readonly acl: ACL; + public readonly appManager: AppManager; + protected plugins = new Map(); public listenServer: Server; - constructor(options: ApplicationOptions) { + constructor(public options: ApplicationOptions) { super(); this.acl = createACL(); @@ -101,6 +104,8 @@ export class Application exten app: this, }); + this.appManager = new AppManager(this); + registerMiddlewares(this, options); if (options.registerActions !== false) { @@ -176,11 +181,20 @@ export class Application exten await this.emitAsync('afterStart', this, options); } + listen(...args): Server { + return this.appManager.listen(...args); + } + async stop(options?: any) { await this.emitAsync('beforeStop', this, options); - // close database connection - await this.db.close(); + try { + // close database connection + // silent if database already closed + await this.db.close(); + } catch (e) { + console.log(e); + } // close http server if (this.listenServer) { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e19d8019d..a2242e721 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -2,3 +2,4 @@ export * from './application'; export * as middlewares from './middlewares'; export * from './plugin'; export { Application as default } from './application'; +export { AppManager } from './app-manager'; diff --git a/packages/server/src/plugin-manager.ts b/packages/server/src/plugin-manager.ts index be8a1cf6b..a9864db29 100644 --- a/packages/server/src/plugin-manager.ts +++ b/packages/server/src/plugin-manager.ts @@ -20,6 +20,10 @@ export class PluginManager { this.app = options.app; } + getPlugins() { + return this.plugins; + } + get(name: string) { return this.plugins.get(name); } diff --git a/packages/server/src/plugin.ts b/packages/server/src/plugin.ts index 694d34928..0bb8f9f6e 100644 --- a/packages/server/src/plugin.ts +++ b/packages/server/src/plugin.ts @@ -1,6 +1,7 @@ import { Database } from '@nocobase/database'; import { Application } from './application'; -import path from 'path'; +import finder from 'find-package-json'; + import { InstallOptions } from './plugin-manager'; export interface PluginInterface { @@ -38,7 +39,9 @@ export abstract class Plugin implements PluginInterface { } getName(): string { - return this.constructor.name; + const path = require.main.children[require.main.children.length - 1].path; + + return ''; } beforeLoad() {} @@ -57,4 +60,10 @@ export abstract class Plugin implements PluginInterface { collectionPath() { return null; } + + protected getPackageName(dirname: string) { + const f = finder(dirname); + const packageObj = f.next().value; + return packageObj['name']; + } } diff --git a/packages/test/src/mockServer.ts b/packages/test/src/mockServer.ts index d36f0c83f..3386da9da 100644 --- a/packages/test/src/mockServer.ts +++ b/packages/test/src/mockServer.ts @@ -1,4 +1,4 @@ -import { mockDatabase } from '@nocobase/database'; +import { Database, mockDatabase } from '@nocobase/database'; import Application, { ApplicationOptions } from '@nocobase/server'; import qs from 'qs'; import supertest, { SuperAgentTest } from 'supertest'; @@ -72,7 +72,7 @@ export class MockServer extends Application { } agent(): SuperAgentTest & { resource: (name: string, resourceOf?: any) => Resource } { - const agent = supertest.agent(this.callback()); + const agent = supertest.agent(this.appManager.callback()); const prefix = this.resourcer.options.prefix; const proxy = new Proxy(agent, { get(target, method: string, receiver) { @@ -130,7 +130,13 @@ export class MockServer extends Application { } export function mockServer(options: ApplicationOptions = {}) { - const database = mockDatabase(options?.database || {}); + let database; + if (options?.database instanceof Database) { + database = options.database; + } else { + database = mockDatabase(options?.database || {}); + } + return new MockServer({ ...options, database, diff --git a/yarn.lock b/yarn.lock index 33549c797..c6f6b2a18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3310,7 +3310,14 @@ resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== -"@types/react-dom@^16.9.8", "@types/react-dom@^17.0.0": +"@types/react-dom@^16.9.8": + version "16.9.14" + resolved "https://registry.npmmirror.com/@types/react-dom/-/react-dom-16.9.14.tgz#674b8f116645fe5266b40b525777fc6bb8eb3bcd" + integrity sha512-FIX2AVmPTGP30OUJ+0vadeIFJJ07Mh1m+U0rxfgyW34p3rTlXI+nlenvAxNn4BP36YyI9IJ/+UJ7Wu22N1pI7A== + dependencies: + "@types/react" "^16" + +"@types/react-dom@^17.0.0": version "17.0.11" resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466" integrity sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q== @@ -3370,7 +3377,7 @@ "@types/history" "*" "@types/react" "*" -"@types/react@*", "@types/react@>=16.9.11", "@types/react@^16.9.43", "@types/react@^17.0.0": +"@types/react@*", "@types/react@>=16.9.11", "@types/react@^17.0.0": version "17.0.34" resolved "https://registry.npmjs.org/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102" integrity sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg== @@ -3379,6 +3386,15 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^16", "@types/react@^16.9.43": + version "16.14.24" + resolved "https://registry.npmmirror.com/@types/react/-/react-16.14.24.tgz#f2c5e9fa78f83f769884b83defcf7924b9eb5c82" + integrity sha512-e7U2WC8XQP/xfR7bwhOhNFZKPTfW1ph+MiqtudKb8tSV8RyCsovQx2sNVtKoOryjxFKpHPPC/yNiGfdeVM5Gyw== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/sax@^1.2.1": version "1.2.3" resolved "https://registry.npmjs.org/@types/sax/-/sax-1.2.3.tgz#b630ac1403ebd7812e0bf9a10de9bf5077afb348" @@ -7324,6 +7340,11 @@ find-cache-dir@^2.0.0: make-dir "^2.0.0" pkg-dir "^3.0.0" +find-package-json@^1.2.0: + version "1.2.0" + resolved "https://registry.npmmirror.com/find-package-json/-/find-package-json-1.2.0.tgz#4057d1b943f82d8445fe52dc9cf456f6b8b58083" + integrity sha512-+SOGcLGYDJHtyqHd87ysBhmaeQ95oWspDKnMXBrnQ9Eq4OkLNqejgoaD8xVWu6GPa0B6roa6KinCMEMcVeqONw== + find-root@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4"