mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 21:49:25 +08:00
365 lines
9.1 KiB
TypeScript
365 lines
9.1 KiB
TypeScript
/**
|
|
* This file is part of the NocoBase (R) project.
|
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
* Authors: NocoBase Team.
|
|
*
|
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
*/
|
|
|
|
import { applyMixins, AsyncEmitter } from '@nocobase/utils';
|
|
import { Mutex } from 'async-mutex';
|
|
import { EventEmitter } from 'events';
|
|
import Application, { ApplicationOptions, MaintainingCommandStatus } from './application';
|
|
import { getErrorLevel } from './errors/handler';
|
|
|
|
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 lastSeenAt: Map<string, number> = new Map();
|
|
|
|
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, options = {}) {
|
|
if (this.appStatus[appName] === status) {
|
|
return;
|
|
}
|
|
|
|
this.appStatus[appName] = status;
|
|
|
|
this.emit('appStatusChanged', {
|
|
appName,
|
|
status,
|
|
options,
|
|
});
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
touchApp(appName: string) {
|
|
if (!this.hasApp(appName)) {
|
|
return;
|
|
}
|
|
|
|
this.lastSeenAt.set(appName, Math.floor(Date.now() / 1000));
|
|
}
|
|
|
|
// 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`);
|
|
}
|
|
|
|
app.logger.info(`add app ${app.name} into supervisor`, { submodule: 'supervisor', method: 'addApp' });
|
|
|
|
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];
|
|
this.lastSeenAt.delete(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 (_app, options) => {
|
|
const { maintainingStatus, options: startOptions } = options;
|
|
|
|
if (
|
|
maintainingStatus &&
|
|
[
|
|
'install',
|
|
'upgrade',
|
|
'refresh',
|
|
'restore',
|
|
'pm.add',
|
|
'pm.update',
|
|
'pm.enable',
|
|
'pm.disable',
|
|
'pm.remove',
|
|
].includes(maintainingStatus.command.name) &&
|
|
!startOptions.recover
|
|
) {
|
|
this.setAppStatus(app.name, 'running', {
|
|
refresh: true,
|
|
});
|
|
} else {
|
|
this.setAppStatus(app.name, 'running');
|
|
}
|
|
});
|
|
|
|
app.on('__stopped', async () => {
|
|
this.setAppStatus(app.name, 'stopped');
|
|
});
|
|
|
|
app.on('maintaining', (maintainingStatus: MaintainingCommandStatus) => {
|
|
const { status, command } = 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':
|
|
{
|
|
const errorLevel = getErrorLevel(maintainingStatus.error);
|
|
|
|
if (errorLevel === 'fatal') {
|
|
this.setAppError(app.name, maintainingStatus.error);
|
|
this.setAppStatus(app.name, 'error');
|
|
break;
|
|
}
|
|
|
|
if (errorLevel === 'warn') {
|
|
this.emit('appError', {
|
|
appName: app.name,
|
|
error: maintainingStatus.error,
|
|
});
|
|
}
|
|
|
|
this.setAppStatus(app.name, this.statusBeforeCommanding[app.name]);
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
applyMixins(AppSupervisor, [AsyncEmitter]);
|