feat: multiple apps admin (#1431)
* fix: dynamic routerBase * fix: start sub app with empty options * chore: sync options * fix: sub app create database * fix: test * fix: deps * feat: register app db creator * feat: default db creator * feat: app options factory * chore: api name * fix: test * fix: running sub app * fix: beforeGetApplication hook * fix: mysql test * fix: appManager get selector * chore: callback --------- Co-authored-by: Chareice <chareice@live.com>
This commit is contained in:
parent
176bd6e7ac
commit
05740672a0
@ -36,5 +36,9 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<script>
|
||||||
|
var match = location.pathname.match(/^\/apps\/([^/]*)\//);
|
||||||
|
window.routerBase = match ? match[0] : "/";
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@ -24,6 +24,10 @@ export class APIClient extends APIClientSDK {
|
|||||||
interceptors() {
|
interceptors() {
|
||||||
this.axios.interceptors.request.use((config) => {
|
this.axios.interceptors.request.use((config) => {
|
||||||
config.headers['X-With-ACL-Meta'] = true;
|
config.headers['X-With-ACL-Meta'] = true;
|
||||||
|
const match = location.pathname.match(/^\/apps\/([^/]*)\//);
|
||||||
|
if (match) {
|
||||||
|
config.headers['X-App'] = match[1];
|
||||||
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
super.interceptors();
|
super.interceptors();
|
||||||
|
@ -8,7 +8,7 @@ type AppSelector = (req: IncomingMessage) => Application | string | undefined |
|
|||||||
export class AppManager extends EventEmitter {
|
export class AppManager extends EventEmitter {
|
||||||
public applications: Map<string, Application> = new Map<string, Application>();
|
public applications: Map<string, Application> = new Map<string, Application>();
|
||||||
|
|
||||||
constructor(private app: Application) {
|
constructor(public app: Application) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
app.on('beforeStop', async (mainApp, options) => {
|
app.on('beforeStop', async (mainApp, options) => {
|
||||||
@ -63,10 +63,13 @@ export class AppManager extends EventEmitter {
|
|||||||
|
|
||||||
callback() {
|
callback() {
|
||||||
return async (req: IncomingMessage, res: ServerResponse) => {
|
return async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
let handleApp: any = this.appSelector(req) || this.app;
|
const appManager = this.app.appManager;
|
||||||
|
|
||||||
|
let handleApp: any = appManager.appSelector(req) || appManager.app;
|
||||||
|
|
||||||
if (typeof handleApp === 'string') {
|
if (typeof handleApp === 'string') {
|
||||||
handleApp = await this.getApplication(handleApp);
|
handleApp = await appManager.getApplication(handleApp);
|
||||||
|
|
||||||
if (!handleApp) {
|
if (!handleApp) {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
return res.end(
|
return res.end(
|
||||||
@ -74,7 +77,7 @@ export class AppManager extends EventEmitter {
|
|||||||
redirectTo: process.env.APP_NOT_FOUND_REDIRECT_TO,
|
redirectTo: process.env.APP_NOT_FOUND_REDIRECT_TO,
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
message: 'Not Found',
|
message: 'Application Not Found',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
@ -41,6 +41,7 @@ export interface ApplicationOptions {
|
|||||||
acl?: boolean;
|
acl?: boolean;
|
||||||
logger?: AppLoggerOptions;
|
logger?: AppLoggerOptions;
|
||||||
pmSock?: string;
|
pmSock?: string;
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DefaultState extends KoaDefaultState {
|
export interface DefaultState extends KoaDefaultState {
|
||||||
@ -220,6 +221,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
return this._logger;
|
return this._logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this.options.name || 'main';
|
||||||
|
}
|
||||||
|
|
||||||
protected init() {
|
protected init() {
|
||||||
const options = this.options;
|
const options = this.options;
|
||||||
const logger = createAppLogger(options.logger);
|
const logger = createAppLogger(options.logger);
|
||||||
@ -523,6 +528,12 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
|
declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
appName: this.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyMixins(Application, [AsyncEmitter]);
|
applyMixins(Application, [AsyncEmitter]);
|
||||||
|
1
packages/plugins/mdg
Symbolic link
1
packages/plugins/mdg
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/Users/chareice/projects/plugins-mdg/
|
@ -21,11 +21,39 @@ describe('multiple apps create', () => {
|
|||||||
await app.destroy();
|
await app.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should register db creator', async () => {
|
||||||
|
const fn = jest.fn();
|
||||||
|
|
||||||
|
const appPlugin = app.getPlugin<PluginMultiAppManager>('PluginMultiAppManager');
|
||||||
|
const defaultDbCreator = appPlugin.appDbCreator;
|
||||||
|
|
||||||
|
appPlugin.setAppDbCreator(async (app) => {
|
||||||
|
fn();
|
||||||
|
await defaultDbCreator(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
const name = `td_${uid()}`;
|
||||||
|
await db.getRepository('applications').create({
|
||||||
|
values: {
|
||||||
|
name,
|
||||||
|
options: {
|
||||||
|
plugins: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.appManager.removeApplication(name);
|
||||||
|
|
||||||
|
expect(fn).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should create application', async () => {
|
it('should create application', async () => {
|
||||||
const name = `td_${uid()}`;
|
const name = `td_${uid()}`;
|
||||||
const miniApp = await db.getRepository('applications').create({
|
const miniApp = await db.getRepository('applications').create({
|
||||||
values: {
|
values: {
|
||||||
name,
|
name,
|
||||||
|
options: {
|
||||||
|
plugins: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -37,6 +65,9 @@ describe('multiple apps create', () => {
|
|||||||
await db.getRepository('applications').create({
|
await db.getRepository('applications').create({
|
||||||
values: {
|
values: {
|
||||||
name,
|
name,
|
||||||
|
options: {
|
||||||
|
plugins: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,27 +1,28 @@
|
|||||||
import Database, { IDatabaseOptions, Model, Transactionable } from '@nocobase/database';
|
import { Model, Transactionable } from '@nocobase/database';
|
||||||
import { Application } from '@nocobase/server';
|
import { Application } from '@nocobase/server';
|
||||||
import lodash from 'lodash';
|
import { AppDbCreator, AppOptionsFactory } from '../server';
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
export interface registerAppOptions extends Transactionable {
|
export interface registerAppOptions extends Transactionable {
|
||||||
skipInstall?: boolean;
|
skipInstall?: boolean;
|
||||||
|
dbCreator: AppDbCreator;
|
||||||
|
appOptionsFactory: AppOptionsFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApplicationModel extends Model {
|
export class ApplicationModel extends Model {
|
||||||
static getDatabaseConfig(app: Application): IDatabaseOptions {
|
|
||||||
const oldConfig =
|
|
||||||
app.options.database instanceof Database
|
|
||||||
? (app.options.database as Database).options
|
|
||||||
: (app.options.database as IDatabaseOptions);
|
|
||||||
|
|
||||||
return lodash.cloneDeep(lodash.omit(oldConfig, ['migrator']));
|
|
||||||
}
|
|
||||||
|
|
||||||
static async handleAppStart(app: Application, options: registerAppOptions) {
|
static async handleAppStart(app: Application, options: registerAppOptions) {
|
||||||
await app.load();
|
await app.load();
|
||||||
if (!options?.skipInstall) {
|
|
||||||
|
if (!(await app.isInstalled())) {
|
||||||
|
await app.db.sync({
|
||||||
|
force: false,
|
||||||
|
alter: {
|
||||||
|
drop: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await app.install();
|
await app.install();
|
||||||
}
|
}
|
||||||
|
|
||||||
await app.start();
|
await app.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,47 +32,26 @@ export class ApplicationModel extends Model {
|
|||||||
|
|
||||||
const AppModel = this.constructor as typeof ApplicationModel;
|
const AppModel = this.constructor as typeof ApplicationModel;
|
||||||
|
|
||||||
const createDatabase = async () => {
|
|
||||||
const database = appName;
|
|
||||||
const { host, port, username, password, dialect } = mainApp.db.options;
|
|
||||||
|
|
||||||
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({
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
user: username,
|
|
||||||
password,
|
|
||||||
database: 'postgres',
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.query(`CREATE DATABASE "${database}"`);
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options?.skipInstall) {
|
|
||||||
await createDatabase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = mainApp.appManager.createApplication(appName, {
|
const app = mainApp.appManager.createApplication(appName, {
|
||||||
...AppModel.initOptions(appName, mainApp),
|
...options.appOptionsFactory(appName, mainApp),
|
||||||
...appOptions,
|
...appOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isInstalled = await (async () => {
|
||||||
|
try {
|
||||||
|
return await app.isInstalled();
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes('does not exist') || e.message.includes('Unknown database')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!isInstalled) {
|
||||||
|
await options.dbCreator(app);
|
||||||
|
}
|
||||||
|
|
||||||
await AppModel.handleAppStart(app, options);
|
await AppModel.handleAppStart(app, options);
|
||||||
|
|
||||||
await AppModel.update(
|
await AppModel.update(
|
||||||
@ -87,25 +67,4 @@ export class ApplicationModel extends Model {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static initOptions(appName: string, mainApp: Application) {
|
|
||||||
const rawDatabaseOptions = this.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,
|
|
||||||
tablePrefix: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,91 @@
|
|||||||
import { AppManager, InstallOptions, Plugin } from '@nocobase/server';
|
import Application, { AppManager, InstallOptions, Plugin } from '@nocobase/server';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
|
import * as path from 'path';
|
||||||
import { ApplicationModel } from './models/application';
|
import { ApplicationModel } from './models/application';
|
||||||
|
import Database, { IDatabaseOptions, Model, Transactionable } from '@nocobase/database';
|
||||||
|
import lodash from 'lodash';
|
||||||
|
|
||||||
|
export type AppDbCreator = (app: Application) => Promise<void>;
|
||||||
|
export type AppOptionsFactory = (appName: string, mainApp: Application) => any;
|
||||||
|
|
||||||
|
const defaultDbCreator = async (app: Application) => {
|
||||||
|
const databaseOptions = app.options.database as any;
|
||||||
|
const { host, port, username, password, dialect, database } = databaseOptions;
|
||||||
|
|
||||||
|
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({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
user: username,
|
||||||
|
password,
|
||||||
|
database: 'postgres',
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query(`CREATE DATABASE "${database}"`);
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultAppOptionsFactory = (appName: string, mainApp: Application) => {
|
||||||
|
const rawDatabaseOptions = PluginMultiAppManager.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,
|
||||||
|
tablePrefix: '',
|
||||||
|
},
|
||||||
|
plugins: ['nocobase'],
|
||||||
|
resourcer: {
|
||||||
|
prefix: '/api',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export class PluginMultiAppManager extends Plugin {
|
export class PluginMultiAppManager extends Plugin {
|
||||||
|
appDbCreator: AppDbCreator = defaultDbCreator;
|
||||||
|
appOptionsFactory: AppOptionsFactory = defaultAppOptionsFactory;
|
||||||
|
|
||||||
|
setAppOptionsFactory(factory: AppOptionsFactory) {
|
||||||
|
this.appOptionsFactory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAppDbCreator(appDbCreator: AppDbCreator) {
|
||||||
|
this.appDbCreator = appDbCreator;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDatabaseConfig(app: Application): IDatabaseOptions {
|
||||||
|
const oldConfig =
|
||||||
|
app.options.database instanceof Database
|
||||||
|
? (app.options.database as Database).options
|
||||||
|
: (app.options.database as IDatabaseOptions);
|
||||||
|
|
||||||
|
return lodash.cloneDeep(lodash.omit(oldConfig, ['migrator']));
|
||||||
|
}
|
||||||
|
|
||||||
async install(options?: InstallOptions) {
|
async install(options?: InstallOptions) {
|
||||||
const repo = this.db.getRepository<any>('collections');
|
const repo = this.db.getRepository<any>('collections');
|
||||||
if (repo) {
|
if (repo) {
|
||||||
@ -10,6 +93,12 @@ export class PluginMultiAppManager extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeLoad(): void {
|
||||||
|
this.app.appManager.setAppSelector((req) => {
|
||||||
|
return (req.headers['x-app'] || null) as any;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
this.db.registerModels({
|
this.db.registerModels({
|
||||||
ApplicationModel,
|
ApplicationModel,
|
||||||
@ -22,7 +111,11 @@ export class PluginMultiAppManager extends Plugin {
|
|||||||
this.db.on('applications.afterCreateWithAssociations', async (model: ApplicationModel, options) => {
|
this.db.on('applications.afterCreateWithAssociations', async (model: ApplicationModel, options) => {
|
||||||
const { transaction } = options;
|
const { transaction } = options;
|
||||||
|
|
||||||
await model.registerToMainApp(this.app, { transaction });
|
await model.registerToMainApp(this.app, {
|
||||||
|
transaction,
|
||||||
|
dbCreator: this.appDbCreator,
|
||||||
|
appOptionsFactory: this.appOptionsFactory,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.db.on('applications.afterDestroy', async (model: ApplicationModel) => {
|
this.db.on('applications.afterDestroy', async (model: ApplicationModel) => {
|
||||||
@ -31,7 +124,7 @@ export class PluginMultiAppManager extends Plugin {
|
|||||||
|
|
||||||
this.app.appManager.on(
|
this.app.appManager.on(
|
||||||
'beforeGetApplication',
|
'beforeGetApplication',
|
||||||
async function lazyLoadApplication({ appManager, name }: { appManager: AppManager; name: string }) {
|
async ({ appManager, name }: { appManager: AppManager; name: string }) => {
|
||||||
if (!appManager.applications.has(name)) {
|
if (!appManager.applications.has(name)) {
|
||||||
const existsApplication = (await this.app.db.getRepository('applications').findOne({
|
const existsApplication = (await this.app.db.getRepository('applications').findOne({
|
||||||
filter: {
|
filter: {
|
||||||
@ -40,7 +133,10 @@ export class PluginMultiAppManager extends Plugin {
|
|||||||
})) as ApplicationModel | null;
|
})) as ApplicationModel | null;
|
||||||
|
|
||||||
if (existsApplication) {
|
if (existsApplication) {
|
||||||
await existsApplication.registerToMainApp(this.app, { skipInstall: true });
|
await existsApplication.registerToMainApp(this.app, {
|
||||||
|
dbCreator: this.appDbCreator,
|
||||||
|
appOptionsFactory: this.appOptionsFactory,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -48,9 +144,7 @@ export class PluginMultiAppManager extends Plugin {
|
|||||||
|
|
||||||
this.app.acl.registerSnippet({
|
this.app.acl.registerSnippet({
|
||||||
name: `pm.${this.name}.applications`,
|
name: `pm.${this.name}.applications`,
|
||||||
actions: [
|
actions: ['applications:*'],
|
||||||
'applications:*',
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
"@nocobase/plugin-export": "0.9.0-alpha.2",
|
"@nocobase/plugin-export": "0.9.0-alpha.2",
|
||||||
"@nocobase/plugin-file-manager": "0.9.0-alpha.2",
|
"@nocobase/plugin-file-manager": "0.9.0-alpha.2",
|
||||||
"@nocobase/plugin-graph-collection-manager": "0.9.0-alpha.2",
|
"@nocobase/plugin-graph-collection-manager": "0.9.0-alpha.2",
|
||||||
|
"@nocobase/plugin-multi-app-manager": "0.9.0-alpha.2",
|
||||||
"@nocobase/plugin-iframe-block": "0.9.0-alpha.2",
|
"@nocobase/plugin-iframe-block": "0.9.0-alpha.2",
|
||||||
"@nocobase/plugin-import": "0.9.0-alpha.2",
|
"@nocobase/plugin-import": "0.9.0-alpha.2",
|
||||||
"@nocobase/plugin-map": "0.9.0-alpha.2",
|
"@nocobase/plugin-map": "0.9.0-alpha.2",
|
||||||
|
@ -32,7 +32,15 @@ export class PresetNocoBase extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getLocalPlugins() {
|
getLocalPlugins() {
|
||||||
const localPlugins = ['sample-hello', 'oidc', 'saml', 'map', 'snapshot-field', 'graph-collection-manager'];
|
const localPlugins = [
|
||||||
|
'sample-hello',
|
||||||
|
'multi-app-manager',
|
||||||
|
'oidc',
|
||||||
|
'saml',
|
||||||
|
'map',
|
||||||
|
'snapshot-field',
|
||||||
|
'graph-collection-manager',
|
||||||
|
];
|
||||||
return localPlugins;
|
return localPlugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user