nocobase/packages/core/server/src/app-supervisor.ts
jack zhang 705b7449f0
feat: new plugin manager, supports adding plugins through UI (#2430)
* refactor: plugin manager page

* fix: bug

* feat: addByNpm api

* fix: improve the addByNpm

* feat: improve applicationPlugins:list api

* fix: re-download npm package when restart app

* fix: plugin delete api

* feat: plugin detail api

* feat: zipUrl add api

* fix: upload api bug

* fix: plugin detail info

* feat: upgrade api

* fix: upload api

* feat: handle plugin load error

* feat: support authToken

* feat: muti lang

* fix: build error

* fix: self review

* Update plugin-manager.ts

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bugs

* fix: detail click and remove isOfficial

* fix: upgrade no refresh

* fix: file size and type check

* fix: bug

* fix: upgrade error

* fix: bug

* fix: bug

* fix: plugin card layout

* fix: handling exceptional cases

* fix: tgz file support

* fix: macos compress file

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: add upgrade npm type

* fix: bugs

* fix: bug

* fix: change plugins static expose url

* fix: api prefix

* fix: bug

* fix: add nginx `/static/plugin/` path

* fix: bugs and pr docker build no dts

* fix: bug

* fix: build tools bug

* fix: improve code

* fix: build bug

* feat: improve plugin info

* fix: ui bug

* fix: plugin document bug

* feat: improve code

* feat: improve code

* feat: process dev deps check

* feat: improve code

* feat: process.env.IS_DEV_CMD

* fix: do not delete the plugin package

* feat: plugin symlink

* fix: tsx watch --ignore=./storage/plugins/**

* fix: test error

* fix: improve code

* fix: improve code

* fix: emitStartedEvent

* fix: improve code

* fix: type error

* fix: test error

* test: console.log

* fix: createStoragePluginSymLink

* fix: clientStaticMiddleware rename to clientStaticUtils

* feat: build tools support plugins folder

* fix: 350px

* fix: error

* feat: client dev support plugin folder

* fix: clear cli options

* fix: typeError: Converting circular structure to JSON

* fix: plugin name

* chore: restart application after command

* feat: upgrade error & docs

* Update v14-changelog.md

* Update v14-changelog.md

* Update v14-changelog.md

* fix: gateway test

* refactor(plugin-workflow): add ready state for gracefully tearing down

* Revert "chore: restart application after command"

This reverts commit 5015274f8e4e06e506e15754b672330330e8c7f8.

* chore: stop application whe restart

* T 1218 change plugin folder (#2629)

* feat: change folder name

* feat: change `pm create` command

* feat:  revert plugin name change

* fix: delete samples

* feat: change plugins folder

* fix: pm create

* feat: update docs

* fix: link package error

* fix: docs

* fix: create command

* fix: pm add error

* fix: create  add build

* fix: pm creatre + add

* feat: add tar command

* fix: docs

* fix: bug

* fix: docs

---------

Co-authored-by: chenos <chenlinxh@gmail.com>

* feat: docs

* Update your-fisrt-plugin.md

* Update your-fisrt-plugin.md

* chore: application reload

* chore: test

* fix: pm add error

* chore: preset install skip exists plugin

* fix: createIfNotExists

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
Co-authored-by: chareice <chareice@live.com>
Co-authored-by: Zhou <zhou.working@gmail.com>
Co-authored-by: mytharcher <mytharcher@gmail.com>
2023-09-12 22:39:23 +08:00

335 lines
8.4 KiB
TypeScript

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 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];
}
// 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`);
}
console.log(`add app ${app.name} into supervisor`);
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];
});
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;
if (
maintainingStatus &&
['install', 'upgrade', 'pm.add', 'pm.update', 'pm.enable', 'pm.disable', 'pm.remove'].includes(
maintainingStatus.command.name,
)
) {
this.setAppStatus(app.name, 'running', {
refresh: true,
});
} else {
this.setAppStatus(app.name, 'running');
}
});
app.on('afterStop', 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]);