/** * 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 { define, observable } from '@formily/reactive'; import { APIClientOptions, getSubAppName } from '@nocobase/sdk'; import { i18n as i18next } from 'i18next'; import get from 'lodash/get'; import merge from 'lodash/merge'; import set from 'lodash/set'; import React, { ComponentType, FC, ReactElement, ReactNode } from 'react'; import { createRoot } from 'react-dom/client'; import { I18nextProvider } from 'react-i18next'; import { Link, NavLink, Navigate } from 'react-router-dom'; import { APIClient, APIClientProvider } from '../api-client'; import { CSSVariableProvider } from '../css-variable'; import { AntdAppProvider, GlobalThemeProvider } from '../global-theme'; import { i18n } from '../i18n'; import { PluginManager, PluginType } from './PluginManager'; import { PluginSettingOptions, PluginSettingsManager } from './PluginSettingsManager'; import { ComponentTypeAndString, RouterManager, RouterOptions } from './RouterManager'; import { WebSocketClient, WebSocketClientOptions } from './WebSocketClient'; import { AppComponent, BlankComponent, defaultAppComponents } from './components'; import { SchemaInitializer, SchemaInitializerManager } from './schema-initializer'; import * as schemaInitializerComponents from './schema-initializer/components'; import { SchemaSettings, SchemaSettingsManager } from './schema-settings'; import { compose, normalizeContainer } from './utils'; import { defineGlobalDeps } from './utils/globalDeps'; import { getRequireJs } from './utils/requirejs'; import { CollectionFieldInterfaceComponentOption } from '../data-source/collection-field-interface/CollectionFieldInterface'; import { CollectionField } from '../data-source/collection-field/CollectionField'; import { DataSourceApplicationProvider } from '../data-source/components/DataSourceApplicationProvider'; import { DataBlockProvider } from '../data-source/data-block/DataBlockProvider'; import { DataSourceManager, type DataSourceManagerOptions } from '../data-source/data-source/DataSourceManager'; import type { CollectionFieldInterfaceFactory } from '../data-source'; import { OpenModeProvider } from '../modules/popup/OpenModeProvider'; import { AppSchemaComponentProvider } from './AppSchemaComponentProvider'; import type { Plugin } from './Plugin'; import { getOperators } from './globalOperators'; import type { RequireJS } from './utils/requirejs'; type JsonLogic = { addOperation: (name: string, fn?: any) => void; rmOperation: (name: string) => void; }; declare global { interface Window { define: RequireJS['define']; } } export type DevDynamicImport = (packageName: string) => Promise<{ default: typeof Plugin }>; export type ComponentAndProps = [ComponentType, T]; export interface ApplicationOptions { name?: string; publicPath?: string; apiClient?: APIClientOptions | APIClient; ws?: WebSocketClientOptions | boolean; i18n?: i18next; providers?: (ComponentType | ComponentAndProps)[]; plugins?: PluginType[]; components?: Record; scopes?: Record; router?: RouterOptions; pluginSettings?: Record; schemaSettings?: SchemaSettings[]; schemaInitializers?: SchemaInitializer[]; designable?: boolean; loadRemotePlugins?: boolean; devDynamicImport?: DevDynamicImport; dataSourceManager?: DataSourceManagerOptions; disableAcl?: boolean; } export class Application { public eventBus = new EventTarget(); public providers: ComponentAndProps[] = []; public router: RouterManager; public scopes: Record = {}; public i18n: i18next; public ws: WebSocketClient; public apiClient: APIClient; public components: Record | any> = { DataBlockProvider, ...defaultAppComponents, ...schemaInitializerComponents, CollectionField, }; public pluginManager: PluginManager; public pluginSettingsManager: PluginSettingsManager; public devDynamicImport: DevDynamicImport; public requirejs: RequireJS; public notification; public schemaInitializerManager: SchemaInitializerManager; public schemaSettingsManager: SchemaSettingsManager; public dataSourceManager: DataSourceManager; public name: string; public globalVars: Record = {}; public jsonLogic: JsonLogic; loading = true; maintained = false; maintaining = false; error = null; hasLoadError = false; private wsAuthorized = false; get pm() { return this.pluginManager; } get disableAcl() { return this.options.disableAcl; } get isWsAuthorized() { return this.wsAuthorized; } setWsAuthorized(authorized: boolean) { this.wsAuthorized = authorized; } constructor(protected options: ApplicationOptions = {}) { this.initRequireJs(); define(this, { maintained: observable.ref, loading: observable.ref, maintaining: observable.ref, error: observable.ref, }); this.devDynamicImport = options.devDynamicImport; this.scopes = merge(this.scopes, options.scopes); this.components = merge(this.components, options.components); this.apiClient = options.apiClient instanceof APIClient ? options.apiClient : new APIClient(options.apiClient); this.apiClient.app = this; this.i18n = options.i18n || i18n; this.router = new RouterManager(options.router, this); this.schemaSettingsManager = new SchemaSettingsManager(options.schemaSettings, this); this.pluginManager = new PluginManager(options.plugins, options.loadRemotePlugins, this); this.schemaInitializerManager = new SchemaInitializerManager(options.schemaInitializers, this); this.dataSourceManager = new DataSourceManager(options.dataSourceManager, this); this.addDefaultProviders(); this.addReactRouterComponents(); this.addProviders(options.providers || []); this.ws = new WebSocketClient(options.ws); this.ws.app = this; this.pluginSettingsManager = new PluginSettingsManager(options.pluginSettings, this); this.addRoutes(); this.name = this.options.name || getSubAppName(options.publicPath) || 'main'; this.i18n.on('languageChanged', (lng) => { this.apiClient.auth.locale = lng; }); this.initListeners(); this.jsonLogic = getOperators(); } private initListeners() { this.eventBus.addEventListener('auth:tokenChanged', (event: CustomEvent) => { this.setTokenInWebSocket(event.detail); }); this.eventBus.addEventListener('maintaining:end', () => { if (this.apiClient.auth.token) { this.setTokenInWebSocket({ token: this.apiClient.auth.token, authenticator: this.apiClient.auth.getAuthenticator(), }); } }); } protected setTokenInWebSocket(options: { token: string; authenticator: string }) { const { token, authenticator } = options; if (this.maintaining) { return; } this.ws.send( JSON.stringify({ type: 'auth:token', payload: { token, authenticator, }, }), ); } setMaintaining(maintaining: boolean) { // if maintaining is the same, do nothing if (this.maintaining === maintaining) { return; } this.maintaining = maintaining; if (!maintaining) { this.eventBus.dispatchEvent(new Event('maintaining:end')); } } private initRequireJs() { this.requirejs = getRequireJs(); defineGlobalDeps(this.requirejs); window.define = this.requirejs.define; } private addDefaultProviders() { this.use(APIClientProvider, { apiClient: this.apiClient }); this.use(I18nextProvider, { i18n: this.i18n }); this.use(GlobalThemeProvider); this.use(CSSVariableProvider); this.use(AppSchemaComponentProvider, { designable: this.options.designable, appName: this.name, components: this.components, scope: this.scopes, }); this.use(AntdAppProvider); this.use(DataSourceApplicationProvider, { dataSourceManager: this.dataSourceManager }); this.use(OpenModeProvider); } private addReactRouterComponents() { this.addComponents({ Link, Navigate: Navigate as ComponentType, NavLink, }); } private addRoutes() { this.router.add('not-found', { path: '*', Component: this.components['AppNotFound'], }); } getOptions() { return this.options; } getName() { return getSubAppName(this.getPublicPath()) || null; } getPublicPath() { let publicPath = this.options.publicPath || '/'; if (!publicPath.endsWith('/')) { publicPath += '/'; } return publicPath; } getApiUrl(pathname = '') { let baseURL = this.apiClient.axios['defaults']['baseURL']; if (!baseURL.startsWith('http://') && !baseURL.startsWith('https://')) { const { protocol, host } = window.location; baseURL = `${protocol}//${host}${baseURL}`; } return baseURL.replace(/\/$/g, '') + '/' + pathname.replace(/^\//g, ''); } getRouteUrl(pathname: string) { return this.getPublicPath() + pathname.replace(/^\//g, ''); } getHref(pathname: string) { const name = this.name; if (name && name !== 'main') { return this.getPublicPath() + 'apps/' + name + '/' + pathname.replace(/^\//g, ''); } return this.getPublicPath() + pathname.replace(/^\//g, ''); } getCollectionManager(dataSource?: string) { return this.dataSourceManager.getDataSource(dataSource)?.collectionManager; } /** * @internal */ getComposeProviders() { const Providers = compose(...this.providers)(BlankComponent); Providers.displayName = 'Providers'; return Providers; } use(component: ComponentType, props?: T) { return this.addProvider(component, props); } addProvider(component: ComponentType, props?: T) { return this.providers.push([component, props]); } addProviders(providers: (ComponentType | [ComponentType, any])[]) { providers.forEach((provider) => { if (Array.isArray(provider)) { this.addProvider(provider[0], provider[1]); } else { this.addProvider(provider); } }); } async load() { try { this.loading = true; await this.loadWebSocket(); await this.pm.load(); } catch (error) { this.hasLoadError = true; //not trigger infinite reload when blocked ip if (error?.response?.data?.errors?.[0]?.code === 'BLOCKED_IP') { this.hasLoadError = false; } if (this.ws.enabled) { await new Promise((resolve) => { setTimeout(() => resolve(null), 1000); }); } const toError = (error) => { if (typeof error?.response?.data === 'string') { const tempElement = document.createElement('div'); tempElement.innerHTML = error?.response?.data; return { message: tempElement.textContent || tempElement.innerText }; } if (error?.response?.data?.error) { return error?.response?.data?.error; } if (error?.response?.data?.errors?.[0]) { return error?.response?.data?.errors?.[0]; } return { message: error?.message }; }; this.error = { code: 'LOAD_ERROR', ...toError(error), }; console.error(error, this.error); } this.loading = false; } async loadWebSocket() { this.eventBus.addEventListener('ws:message:authorized', () => { this.setWsAuthorized(true); }); this.ws.on('message', (event) => { const data = JSON.parse(event.data); 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.setMaintaining(true); this.error = data.payload; } else { if (this.hasLoadError) { window.location.reload(); } this.setMaintaining(false); this.maintained = true; this.error = null; const type = data.type; if (!type) { return; } const eventName = `ws:message:${type}`; this.eventBus.dispatchEvent(new CustomEvent(eventName, { detail: data.payload })); } }); this.ws.on('serverDown', () => { this.maintaining = true; this.maintained = false; }); this.ws.on('open', () => { const token = this.apiClient.auth.token; if (token) { this.setTokenInWebSocket({ token, authenticator: this.apiClient.auth.getAuthenticator() }); } }); this.ws.connect(); } getComponent(Component: ComponentTypeAndString, isShowError = true): ComponentType | undefined { const showError = (msg: string) => isShowError && console.error(msg); if (!Component) { showError(`getComponent called with ${Component}`); return; } // ClassComponent or FunctionComponent if (typeof Component === 'function') return Component; // Component is a string, try to get it from this.components if (typeof Component === 'string') { const res = get(this.components, Component) as ComponentType; if (!res) { showError(`Component ${Component} not found`); return; } return res; } showError(`Component ${Component} should be a string or a React component`); return; } renderComponent(Component: ComponentTypeAndString, props?: T, children?: ReactNode): ReactElement { return React.createElement(this.getComponent(Component), props, children); } /** * @internal use addComponents({ SomeComponent }) instead */ protected addComponent(component: ComponentType, name?: string) { const componentName = name || component.displayName || component.name; if (!componentName) { console.error('Component must have a displayName or pass name as second argument'); return; } set(this.components, componentName, component); } addComponents(components: Record) { Object.keys(components).forEach((name) => { this.addComponent(components[name], name); }); } addScopes(scopes: Record) { this.scopes = merge(this.scopes, scopes); } getRootComponent() { const Root: FC<{ children?: React.ReactNode }> = ({ children }) => ( {children} ); return Root; } mount(containerOrSelector: Element | ShadowRoot | string) { const container = normalizeContainer(containerOrSelector); if (!container) return; const App = this.getRootComponent(); const root = createRoot(container); root.render(); return root; } addFieldInterfaces(fieldInterfaceClasses: CollectionFieldInterfaceFactory[] = []) { return this.dataSourceManager.collectionFieldInterfaceManager.addFieldInterfaces(fieldInterfaceClasses); } addFieldInterfaceComponentOption(fieldName: string, componentOption: CollectionFieldInterfaceComponentOption) { return this.dataSourceManager.collectionFieldInterfaceManager.addFieldInterfaceComponentOption( fieldName, componentOption, ); } addGlobalVar(key: string, value: any) { set(this.globalVars, key, value); } getGlobalVar(key) { return get(this.globalVars, key); } registerOperators(key, operator) { this.jsonLogic[key] = operator; } getOperator(key) { return this.jsonLogic[key]; } }