diff --git a/packages/core/client/src/application/Application.tsx b/packages/core/client/src/application/Application.tsx index 5aaac8993..c91535a7d 100644 --- a/packages/core/client/src/application/Application.tsx +++ b/packages/core/client/src/application/Application.tsx @@ -51,6 +51,7 @@ export class Application { public pm: PluginManager; public devDynamicImport: DevDynamicImport; public requirejs: RequireJS; + public notification; loading = true; maintained = false; maintaining = false; @@ -128,14 +129,18 @@ export class Application { this.ws.on('message', (event) => { const data = JSON.parse(event.data); console.log(data.payload); + if (data?.payload?.refresh) { + window.location.reload(); + return; + } + if (data.type === 'notification') { + this.notification[data.payload?.type || 'info']({ message: data.payload?.message }); + return; + } const maintaining = data.type === 'maintaining' && data.payload.code !== 'APP_RUNNING'; if (maintaining) { this.maintaining = true; this.error = data.payload; - } else if (this.maintaining) { - // && !this.maintained - window.location.reload(); - return; } else { this.maintaining = false; this.maintained = true; diff --git a/packages/core/client/src/global-theme/AntdAppProvider.tsx b/packages/core/client/src/global-theme/AntdAppProvider.tsx index 5d6eeb054..d01c2b2e2 100644 --- a/packages/core/client/src/global-theme/AntdAppProvider.tsx +++ b/packages/core/client/src/global-theme/AntdAppProvider.tsx @@ -1,13 +1,16 @@ import { App } from 'antd'; import React, { memo, useEffect } from 'react'; import { useAPIClient } from '../api-client'; +import { useApp } from '../application'; const AppInner = memo(({ children }: { children: React.ReactNode }) => { + const app = useApp(); const { notification } = App.useApp(); const apiClient = useAPIClient(); useEffect(() => { apiClient.notification = notification; + app.notification = notification; }, [notification]); return <>{children}; diff --git a/packages/core/client/src/pm/Card.tsx b/packages/core/client/src/pm/Card.tsx index c80ef77af..f8c907e73 100644 --- a/packages/core/client/src/pm/Card.tsx +++ b/packages/core/client/src/pm/Card.tsx @@ -260,22 +260,11 @@ export const PluginCard = (props: { data: IPluginData }) => { size={'small'} onChange={async (checked, e) => { e.stopPropagation(); - // modal.warning({ - // title: checked ? t('Plugin starting') : t('Plugin stopping'), - // content: t('The application is reloading, please do not close the page.'), - // okButtonProps: { - // style: { - // display: 'none', - // }, - // }, - // }); await api.request({ url: `pm:${checked ? 'enable' : 'disable'}/${name}`, }); - // window.location.reload(); - // message.success(checked ? t('插件激活成功') : t('插件禁用成功')); }} - defaultChecked={enabled} + checked={enabled} >, ].filter(Boolean), [api, enabled, navigate, id, name, t], diff --git a/packages/core/server/src/__tests__/gateway.test.ts b/packages/core/server/src/__tests__/gateway.test.ts index 344401764..e5bf38d05 100644 --- a/packages/core/server/src/__tests__/gateway.test.ts +++ b/packages/core/server/src/__tests__/gateway.test.ts @@ -253,6 +253,31 @@ describe('gateway', () => { }); }); + it('should receive refresh true when app installed', async () => { + await connectClient(port); + const app = new Application({ + database: { + dialect: 'sqlite', + storage: ':memory:', + }, + }); + + await waitSecond(); + + await app.runCommand('start'); + await app.runCommand('install'); + + await waitSecond(); + + expect(getLastMessage()).toMatchObject({ + type: 'maintaining', + payload: { + code: 'APP_RUNNING', + refresh: true, + }, + }); + }); + it('should receive app running message when command end', async () => { await connectClient(port); const app = new Application({ diff --git a/packages/core/server/src/app-supervisor.ts b/packages/core/server/src/app-supervisor.ts index b958427d7..72fad414d 100644 --- a/packages/core/server/src/app-supervisor.ts +++ b/packages/core/server/src/app-supervisor.ts @@ -2,6 +2,7 @@ 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; @@ -90,7 +91,7 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter { AppSupervisor.instance = null; } - setAppStatus(appName: string, status: AppStatus) { + setAppStatus(appName: string, status: AppStatus, options = {}) { if (this.appStatus[appName] === status) { return; } @@ -100,6 +101,7 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter { this.emit('appStatusChanged', { appName, status, + options, }); } @@ -257,8 +259,18 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter { }); }); - app.on('__started', async () => { - this.setAppStatus(app.name, 'running'); + app.on('__started', async (_app, options) => { + const { maintainingStatus } = options; + if ( + maintainingStatus && + ['install', 'upgrade', 'pm.enable', 'pm.disable'].includes(maintainingStatus.command.name) + ) { + this.setAppStatus(app.name, 'running', { + refresh: true, + }); + } else { + this.setAppStatus(app.name, 'running'); + } }); app.on('afterStop', async () => { @@ -266,7 +278,7 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter { }); app.on('maintaining', (maintainingStatus: MaintainingCommandStatus) => { - const { status } = maintainingStatus; + const { status, command } = maintainingStatus; switch (status) { case 'command_begin': @@ -294,8 +306,22 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter { break; case 'command_error': { - this.setAppError(app.name, maintainingStatus.error); - this.setAppStatus(app.name, '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; } diff --git a/packages/core/server/src/application.ts b/packages/core/server/src/application.ts index 86542aaf3..4f1d3e1c7 100644 --- a/packages/core/server/src/application.ts +++ b/packages/core/server/src/application.ts @@ -360,7 +360,7 @@ export class Application exten } createCli() { - return new Command('nocobase') + const command = new Command('nocobase') .usage('[command] [options]') .hook('preAction', async (_, actionCommand) => { this.activatedCommand = { @@ -385,9 +385,15 @@ export class Application exten await this.restart(); } }); + + command.exitOverride((err) => { + throw err; + }); + + return command; } - async runAsCLI(argv = process.argv, options?: ParseOptions) { + async runAsCLI(argv = process.argv, options?: ParseOptions & { throwError?: boolean }) { if (this.activatedCommand) { return; } @@ -404,12 +410,21 @@ export class Application exten return command; } catch (error) { - console.log(`run command ${this.activatedCommand.name} error:`, error); + if (!this.activatedCommand) { + this.activatedCommand = { + name: 'unknown', + }; + } + this.setMaintaining({ status: 'command_error', command: this.activatedCommand, error, }); + + if (options?.throwError) { + throw error; + } } finally { this.activatedCommand = null; } @@ -439,7 +454,10 @@ export class Application exten this.setMaintainingMessage('emit afterStart'); await this.emitAsync('afterStart', this, options); - await this.emitAsync('__started', this, options); + await this.emitAsync('__started', this, { + maintainingStatus: lodash.cloneDeep(this._maintainingCommandStatus), + }); + this.stopped = false; } diff --git a/packages/core/server/src/commands/pm.ts b/packages/core/server/src/commands/pm.ts index b420500f2..56999b827 100644 --- a/packages/core/server/src/commands/pm.ts +++ b/packages/core/server/src/commands/pm.ts @@ -1,4 +1,5 @@ import Application from '../application'; +import { PluginCommandError } from '../errors/plugin-command-error'; export default (app: Application) => { const pm = app.command('pm'); @@ -21,11 +22,7 @@ export default (app: Application) => { try { await app.pm.enable(plugins); } catch (error) { - app.log.debug(`Failed to enable plugin: ${error.message}`); - app.setMaintainingMessage(`Failed to enable plugin: ${error.message}`); - await new Promise((resolve) => { - setTimeout(() => resolve(null), 10000); - }); + throw new PluginCommandError(`Failed to enable plugin: ${error.message}`); } }); @@ -35,11 +32,7 @@ export default (app: Application) => { try { await app.pm.disable(plugins); } catch (error) { - app.log.debug(`Failed to disable plugin: ${error.message}`); - app.setMaintainingMessage(`Failed to disable plugin: ${error.message}`); - await new Promise((resolve) => { - setTimeout(() => resolve(null), 10000); - }); + throw new PluginCommandError(`Failed to disable plugin: ${error.message}`); } }); diff --git a/packages/core/server/src/errors/handler.ts b/packages/core/server/src/errors/handler.ts new file mode 100644 index 000000000..bee5be442 --- /dev/null +++ b/packages/core/server/src/errors/handler.ts @@ -0,0 +1,16 @@ +import { PluginCommandError } from './plugin-command-error'; + +type ErrorLevel = 'fatal' | 'silly' | 'warn'; + +export function getErrorLevel(e: Error): ErrorLevel { + // @ts-ignore + if (e.code === 'commander.unknownCommand') { + return 'silly'; + } + + if (e instanceof PluginCommandError) { + return 'warn'; + } + + return 'fatal'; +} diff --git a/packages/core/server/src/errors/plugin-command-error.ts b/packages/core/server/src/errors/plugin-command-error.ts new file mode 100644 index 000000000..3e6e5c4ec --- /dev/null +++ b/packages/core/server/src/errors/plugin-command-error.ts @@ -0,0 +1 @@ +export class PluginCommandError extends Error {} diff --git a/packages/core/server/src/gateway/index.ts b/packages/core/server/src/gateway/index.ts index a0acb2d34..c34aa68b4 100644 --- a/packages/core/server/src/gateway/index.ts +++ b/packages/core/server/src/gateway/index.ts @@ -207,7 +207,8 @@ export class Gateway extends EventEmitter { const ipcClient = await this.tryConnectToIPCServer(); if (ipcClient) { - ipcClient.write({ type: 'passCliArgv', payload: { argv: process.argv } }); + await ipcClient.write({ type: 'passCliArgv', payload: { argv: process.argv } }); + // should wait for server response ipcClient.close(); return; } diff --git a/packages/core/server/src/gateway/ipc-socket-client.ts b/packages/core/server/src/gateway/ipc-socket-client.ts index c3e79f6a7..8da38ca1a 100644 --- a/packages/core/server/src/gateway/ipc-socket-client.ts +++ b/packages/core/server/src/gateway/ipc-socket-client.ts @@ -1,21 +1,38 @@ import net from 'net'; +import * as events from 'events'; -const writeJSON = (socket: net.Socket, data: object) => { +export const writeJSON = (socket: net.Socket, data: object) => { socket.write(JSON.stringify(data) + '\n', 'utf8'); }; -export class IPCSocketClient { +export class IPCSocketClient extends events.EventEmitter { client: net.Socket; constructor(client: net.Socket) { + super(); + this.client = client; + + this.client.on('data', (data) => { + const dataAsString = data.toString(); + const messages = dataAsString.split('\n'); + + for (const message of messages) { + if (message.length === 0) { + continue; + } + + const dataObj = JSON.parse(message); + + this.handleServerMessage(dataObj); + } + }); } static async getConnection(serverPath: string) { return new Promise((resolve, reject) => { const client = net.createConnection({ path: serverPath }, () => { // 'connect' listener. - console.log('connected to server!'); resolve(new IPCSocketClient(client)); }); client.on('error', (err) => { @@ -24,11 +41,29 @@ export class IPCSocketClient { }); } + async handleServerMessage({ type, payload }) { + switch (type) { + case 'error': + console.error(payload.message); + break; + case 'success': + console.log('success'); + break; + default: + console.log({ type, payload }); + break; + } + + this.emit('response', { type, payload }); + } + close() { this.client.end(); } write(data: any) { writeJSON(this.client, data); + + return new Promise((resolve) => this.once('response', resolve)); } } diff --git a/packages/core/server/src/gateway/ipc-socket-server.ts b/packages/core/server/src/gateway/ipc-socket-server.ts index 63fc753c9..98e5a22bb 100644 --- a/packages/core/server/src/gateway/ipc-socket-server.ts +++ b/packages/core/server/src/gateway/ipc-socket-server.ts @@ -1,7 +1,7 @@ import net from 'net'; import fs from 'fs'; -import { Gateway } from '../gateway'; import { AppSupervisor } from '../app-supervisor'; +import { writeJSON } from './ipc-socket-client'; export class IPCSocketServer { socketServer: net.Server; @@ -34,7 +34,20 @@ export class IPCSocketServer { const dataObj = JSON.parse(message); - IPCSocketServer.handleClientMessage(dataObj); + IPCSocketServer.handleClientMessage(dataObj) + .then(() => { + writeJSON(c, { + type: 'success', + }); + }) + .catch((err) => { + writeJSON(c, { + type: 'error', + payload: { + message: err.message, + }, + }); + }); } }); }); @@ -53,8 +66,14 @@ export class IPCSocketServer { const argv = payload.argv; const mainApp = await AppSupervisor.getInstance().getApp('main'); - mainApp.runAsCLI(argv); + + return mainApp.runAsCLI(argv, { + from: 'node', + throwError: true, + }); } + + throw new Error(`Unknown message type ${type}`); } close() { diff --git a/packages/core/server/src/gateway/ws-server.ts b/packages/core/server/src/gateway/ws-server.ts index 3e3a77b53..97e94ad52 100644 --- a/packages/core/server/src/gateway/ws-server.ts +++ b/packages/core/server/src/gateway/ws-server.ts @@ -59,6 +59,16 @@ export class WSServer { }); }); + AppSupervisor.getInstance().on('appError', async ({ appName, error }) => { + this.sendToConnectionsByTag('app', appName, { + type: 'notification', + payload: { + message: error.message, + type: 'error', + }, + }); + }); + AppSupervisor.getInstance().on('appMaintainingMessageChanged', async ({ appName, message, command, status }) => { const app = await AppSupervisor.getInstance().getApp(appName, { withOutBootStrap: true, @@ -76,16 +86,18 @@ export class WSServer { }); }); - AppSupervisor.getInstance().on('appStatusChanged', async ({ appName, status }) => { + AppSupervisor.getInstance().on('appStatusChanged', async ({ appName, status, options }) => { const app = await AppSupervisor.getInstance().getApp(appName, { withOutBootStrap: true, }); const payload = getPayloadByErrorCode(status, { app, appName }); - console.log(`send payload ${JSON.stringify(payload)}`); this.sendToConnectionsByTag('app', appName, { type: 'maintaining', - payload, + payload: { + ...payload, + ...options, + }, }); }); }