/** * 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; 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; public apps: { [appName: string]: Application; } = {}; public lastSeenAt: Map = 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]);