mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 17:46:36 +00:00
770 lines
19 KiB
TypeScript
770 lines
19 KiB
TypeScript
import { ACL } from '@nocobase/acl';
|
|
import { registerActions } from '@nocobase/actions';
|
|
import { actions as authActions, AuthManager, AuthManagerOptions } from '@nocobase/auth';
|
|
import { Cache, createCache, ICacheConfig } from '@nocobase/cache';
|
|
import Database, { CollectionOptions, IDatabaseOptions } from '@nocobase/database';
|
|
import { AppLoggerOptions, createAppLogger, Logger } from '@nocobase/logger';
|
|
import { ResourceOptions, Resourcer } from '@nocobase/resourcer';
|
|
import { applyMixins, AsyncEmitter, measureExecutionTime, Toposort, ToposortOptions } from '@nocobase/utils';
|
|
import chalk from 'chalk';
|
|
import { Command, CommandOptions, ParseOptions } from 'commander';
|
|
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 lodash from 'lodash';
|
|
import { createACL } from './acl';
|
|
import { AppCommand } from './app-command';
|
|
import { AppSupervisor } from './app-supervisor';
|
|
import { registerCli } from './commands';
|
|
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';
|
|
import { CronJobManager } from './cron/cron-job-manager';
|
|
|
|
const packageJson = require('../package.json');
|
|
|
|
export type PluginType = string | typeof Plugin;
|
|
export type PluginConfiguration = PluginType | [PluginType, any];
|
|
|
|
export interface ResourcerOptions {
|
|
prefix?: string;
|
|
}
|
|
|
|
export interface ApplicationOptions {
|
|
database?: IDatabaseOptions | Database;
|
|
cache?: ICacheConfig | ICacheConfig[];
|
|
resourcer?: ResourcerOptions;
|
|
bodyParser?: any;
|
|
cors?: any;
|
|
dataWrapping?: boolean;
|
|
registerActions?: boolean;
|
|
i18n?: i18n | InitOptions;
|
|
plugins?: PluginConfiguration[];
|
|
acl?: boolean;
|
|
logger?: AppLoggerOptions;
|
|
pmSock?: string;
|
|
name?: string;
|
|
authManager?: AuthManagerOptions;
|
|
}
|
|
|
|
export interface DefaultState extends KoaDefaultState {
|
|
currentUser?: any;
|
|
|
|
[key: string]: any;
|
|
}
|
|
|
|
export interface DefaultContext extends KoaDefaultContext {
|
|
db: Database;
|
|
cache: Cache;
|
|
resourcer: Resourcer;
|
|
i18n: any;
|
|
|
|
[key: string]: any;
|
|
}
|
|
|
|
interface ActionsOptions {
|
|
resourceName?: string;
|
|
resourceNames?: string[];
|
|
}
|
|
|
|
interface ListenOptions {
|
|
port?: number | undefined;
|
|
host?: string | undefined;
|
|
backlog?: number | undefined;
|
|
path?: string | undefined;
|
|
exclusive?: boolean | undefined;
|
|
readableAll?: boolean | undefined;
|
|
writableAll?: boolean | undefined;
|
|
/**
|
|
* @default false
|
|
*/
|
|
ipv6Only?: boolean | undefined;
|
|
signal?: AbortSignal | undefined;
|
|
}
|
|
|
|
interface StartOptions {
|
|
cliArgs?: any[];
|
|
dbSync?: boolean;
|
|
checkInstall?: boolean;
|
|
recover?: boolean;
|
|
}
|
|
|
|
type MaintainingStatus = 'command_begin' | 'command_end' | 'command_running' | 'command_error';
|
|
|
|
export type MaintainingCommandStatus = {
|
|
command: {
|
|
name: string;
|
|
};
|
|
status: MaintainingStatus;
|
|
error?: Error;
|
|
};
|
|
|
|
export class Application<StateT = DefaultState, ContextT = DefaultContext> extends Koa implements AsyncEmitter {
|
|
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;
|
|
private _actionCommand: Command;
|
|
|
|
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 _cronJobManager: CronJobManager;
|
|
|
|
get cronJobManager() {
|
|
return this._cronJobManager;
|
|
}
|
|
|
|
protected _db: Database;
|
|
|
|
get db() {
|
|
return this._db;
|
|
}
|
|
|
|
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: AppCommand;
|
|
|
|
get cli() {
|
|
return this._cli;
|
|
}
|
|
|
|
protected _i18n: i18n;
|
|
|
|
get i18n() {
|
|
return this._i18n;
|
|
}
|
|
|
|
protected _pm: PluginManager;
|
|
|
|
get pm() {
|
|
return this._pm;
|
|
}
|
|
|
|
protected _acl: ACL;
|
|
|
|
get acl() {
|
|
return this._acl;
|
|
}
|
|
|
|
protected _authManager: AuthManager;
|
|
|
|
get authManager() {
|
|
return this._authManager;
|
|
}
|
|
|
|
protected _locales: Locale;
|
|
|
|
get locales() {
|
|
return this._locales;
|
|
}
|
|
|
|
protected _version: ApplicationVersion;
|
|
|
|
get version() {
|
|
return this._version;
|
|
}
|
|
|
|
get log() {
|
|
return this._logger;
|
|
}
|
|
|
|
get name() {
|
|
return this.options.name || 'main';
|
|
}
|
|
|
|
isMaintaining() {
|
|
return this._maintaining;
|
|
}
|
|
|
|
getMaintaining() {
|
|
return this._maintainingCommandStatus;
|
|
}
|
|
|
|
setMaintaining(_maintainingCommandStatus: MaintainingCommandStatus) {
|
|
this._maintainingCommandStatus = _maintainingCommandStatus;
|
|
|
|
this.emit('maintaining', _maintainingCommandStatus);
|
|
|
|
if (_maintainingCommandStatus.status == 'command_end') {
|
|
this._maintaining = false;
|
|
return;
|
|
}
|
|
|
|
this._maintaining = true;
|
|
}
|
|
|
|
setMaintainingMessage(message: string) {
|
|
this._maintainingMessage = message;
|
|
|
|
this.emit('maintainingMessageChanged', {
|
|
message: this._maintainingMessage,
|
|
maintainingStatus: this._maintainingCommandStatus,
|
|
});
|
|
}
|
|
|
|
getVersion() {
|
|
return packageJson.version;
|
|
}
|
|
|
|
plugin<O = any>(pluginClass: any, options?: O) {
|
|
this.log.debug(`add plugin ${pluginClass.name}`);
|
|
this.pm.addPreset(pluginClass, options);
|
|
}
|
|
|
|
// @ts-ignore
|
|
use<NewStateT = {}, NewContextT = {}>(
|
|
middleware: Koa.Middleware<StateT & NewStateT, ContextT & NewContextT>,
|
|
options?: ToposortOptions,
|
|
) {
|
|
this.middleware.add(middleware, options);
|
|
return this;
|
|
}
|
|
|
|
callback() {
|
|
const fn = compose(this.middleware.nodes);
|
|
|
|
if (!this.listenerCount('error')) this.on('error', this.onerror);
|
|
|
|
return (req: IncomingMessage, res: ServerResponse) => {
|
|
const ctx = this.createContext(req, res);
|
|
|
|
// @ts-ignore
|
|
return this.handleRequest(ctx, fn);
|
|
};
|
|
}
|
|
|
|
collection(options: CollectionOptions) {
|
|
return this.db.collection(options);
|
|
}
|
|
|
|
resource(options: ResourceOptions) {
|
|
return this.resourcer.define(options);
|
|
}
|
|
|
|
actions(handlers: any, options?: ActionsOptions) {
|
|
return this.resourcer.registerActions(handlers);
|
|
}
|
|
|
|
command(name: string, desc?: string, opts?: CommandOptions): AppCommand {
|
|
return this.cli.command(name, desc, opts).allowUnknownOption();
|
|
}
|
|
|
|
findCommand(name: string): Command {
|
|
return (this.cli as any)._findCommand(name);
|
|
}
|
|
|
|
async load(options?: any) {
|
|
if (this._loaded) {
|
|
return;
|
|
}
|
|
|
|
if (options?.reload) {
|
|
this.setMaintainingMessage('app reload');
|
|
this.log.info(`app.reload()`);
|
|
const oldDb = this._db;
|
|
this.init();
|
|
if (!oldDb.closed()) {
|
|
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.emitAsync('beforeReload', this, options);
|
|
|
|
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 | typeof Plugin) {
|
|
return this.pm.get(name) as P;
|
|
}
|
|
|
|
async parse(argv = process.argv) {
|
|
return this.runAsCLI(argv);
|
|
}
|
|
|
|
async authenticate() {
|
|
if (this._authenticated) {
|
|
return;
|
|
}
|
|
this._authenticated = true;
|
|
await this.db.auth();
|
|
await this.dbVersionCheck({ exit: true });
|
|
await this.db.prepare();
|
|
}
|
|
|
|
async runCommand(command: string, ...args: any[]) {
|
|
return await this.runAsCLI([command, ...args], { from: 'user' });
|
|
}
|
|
|
|
createCli() {
|
|
const command = new AppCommand('nocobase')
|
|
.usage('[command] [options]')
|
|
.hook('preAction', async (_, actionCommand) => {
|
|
this._actionCommand = actionCommand;
|
|
this.activatedCommand = {
|
|
name: getCommandFullName(actionCommand),
|
|
};
|
|
|
|
this.setMaintaining({
|
|
status: 'command_begin',
|
|
command: this.activatedCommand,
|
|
});
|
|
|
|
this.setMaintaining({
|
|
status: 'command_running',
|
|
command: this.activatedCommand,
|
|
});
|
|
|
|
await this.authenticate();
|
|
await this.load();
|
|
})
|
|
.hook('postAction', async (_, actionCommand) => {
|
|
if (this._maintainingStatusBeforeCommand?.error && this._started) {
|
|
await this.restart();
|
|
}
|
|
});
|
|
|
|
command.exitOverride((err) => {
|
|
throw err;
|
|
});
|
|
|
|
return command;
|
|
}
|
|
|
|
async runAsCLI(argv = process.argv, options?: ParseOptions & { throwError?: boolean }) {
|
|
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({ error });
|
|
if (!this.activatedCommand) {
|
|
this.activatedCommand = {
|
|
name: 'unknown',
|
|
};
|
|
}
|
|
|
|
this.setMaintaining({
|
|
status: 'command_error',
|
|
command: this.activatedCommand,
|
|
error,
|
|
});
|
|
|
|
if (options?.throwError) {
|
|
throw error;
|
|
}
|
|
} finally {
|
|
const _actionCommand = this._actionCommand;
|
|
if (_actionCommand) {
|
|
const options = _actionCommand['options'];
|
|
_actionCommand['_optionValues'] = {};
|
|
_actionCommand['_optionValueSources'] = {};
|
|
_actionCommand['options'] = [];
|
|
for (const option of options) {
|
|
_actionCommand.addOption(option);
|
|
}
|
|
}
|
|
this._actionCommand = null;
|
|
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();
|
|
}
|
|
|
|
this.setMaintainingMessage('emit beforeStart');
|
|
await this.emitAsync('beforeStart', this, options);
|
|
|
|
this.setMaintainingMessage('emit afterStart');
|
|
await this.emitAsync('afterStart', this, options);
|
|
await this.emitStartedEvent(options);
|
|
|
|
this.stopped = false;
|
|
}
|
|
|
|
async emitStartedEvent(options: StartOptions = {}) {
|
|
await this.emitAsync('__started', this, {
|
|
maintainingStatus: lodash.cloneDeep(this._maintainingCommandStatus),
|
|
options,
|
|
});
|
|
}
|
|
|
|
async isStarted() {
|
|
return this._started;
|
|
}
|
|
|
|
async tryReloadOrRestart(options: StartOptions = {}) {
|
|
if (this._started) {
|
|
await this.restart(options);
|
|
} else {
|
|
await this.reload(options);
|
|
}
|
|
}
|
|
|
|
async restart(options: StartOptions = {}) {
|
|
if (!this._started) {
|
|
return;
|
|
}
|
|
|
|
this._started = false;
|
|
await this.emitAsync('beforeStop');
|
|
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;
|
|
}
|
|
|
|
await this.emitAsync('beforeStop', this, options);
|
|
|
|
try {
|
|
// close database connection
|
|
// silent if database already closed
|
|
if (!this.db.closed()) {
|
|
this.logger.info(`close db`);
|
|
await this.db.close();
|
|
}
|
|
} catch (e) {
|
|
this.log.error(e);
|
|
}
|
|
|
|
await this.emitAsync('afterStop', this, options);
|
|
|
|
this.stopped = true;
|
|
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 }) {
|
|
const r = await this.db.version.satisfies({
|
|
mysql: '>=8.0.17',
|
|
sqlite: '3.x',
|
|
postgres: '>=10',
|
|
});
|
|
|
|
if (!r) {
|
|
console.log(chalk.red('The database only supports MySQL 8.0.17 and above, SQLite 3.x and PostgreSQL 10+'));
|
|
if (options?.exit) {
|
|
process.exit();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (this.db.inDialect('mysql')) {
|
|
const result = await this.db.sequelize.query(`SHOW VARIABLES LIKE 'lower_case_table_names'`, { plain: true });
|
|
if (result?.Value === '1' && !this.db.options.underscored) {
|
|
console.log(
|
|
`Your database lower_case_table_names=1, please add ${chalk.yellow('DB_UNDERSCORED=true')} to the .env file`,
|
|
);
|
|
if (options?.exit) {
|
|
process.exit();
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async isInstalled() {
|
|
return (
|
|
(await this.db.collectionExistsInDb('applicationVersion')) || (await this.db.collectionExistsInDb('collections'))
|
|
);
|
|
}
|
|
|
|
async install(options: InstallOptions = {}) {
|
|
this.setMaintainingMessage('installing app...');
|
|
this.log.debug('Database dialect: ' + this.db.sequelize.getDialect());
|
|
|
|
if (options?.clean || options?.sync?.force) {
|
|
this.log.debug('truncate database');
|
|
await this.db.clean({ drop: true });
|
|
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);
|
|
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 = {}) {
|
|
await this.emitAsync('beforeUpgrade', this, options);
|
|
const force = false;
|
|
|
|
await measureExecutionTime(async () => {
|
|
await this.db.migrator.up();
|
|
}, 'Migrator');
|
|
|
|
await measureExecutionTime(async () => {
|
|
await this.db.sync({
|
|
force,
|
|
alter: {
|
|
drop: force,
|
|
},
|
|
});
|
|
}, 'Sync');
|
|
|
|
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 measureExecutionTime(async () => {
|
|
await this.restart();
|
|
}, 'Restart');
|
|
}
|
|
}
|
|
|
|
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._cronJobManager = new CronJobManager(this);
|
|
|
|
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.options.authManager || {}),
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
protected 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]);
|
|
|
|
export default Application;
|