| /** |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| import { |
| FeatureFlag, |
| SupersetClient, |
| isFeatureEnabled, |
| logging, |
| } from '@superset-ui/core'; |
| import type { contributions, core } from '@apache-superset/core'; |
| import { ExtensionContext } from '../core/models'; |
| |
| class ExtensionsManager { |
| private static instance: ExtensionsManager; |
| |
| private extensionIndex: Map<string, core.Extension> = new Map(); |
| |
| private contextIndex: Map<string, ExtensionContext> = new Map(); |
| |
| private extensionContributions: Map< |
| string, |
| { |
| menus?: Record<string, contributions.MenuContribution>; |
| views?: Record<string, contributions.ViewContribution[]>; |
| commands?: contributions.CommandContribution[]; |
| } |
| > = new Map(); |
| |
| // eslint-disable-next-line no-useless-constructor |
| private constructor() { |
| // Private constructor for singleton pattern |
| } |
| |
| /** |
| * Singleton instance getter. |
| * @returns The singleton instance of ExtensionsManager. |
| */ |
| public static getInstance(): ExtensionsManager { |
| if (!ExtensionsManager.instance) { |
| ExtensionsManager.instance = new ExtensionsManager(); |
| } |
| return ExtensionsManager.instance; |
| } |
| |
| /** |
| * Initializes extensions. |
| * @throws Error if initialization fails. |
| */ |
| public async initializeExtensions(): Promise<void> { |
| if (!isFeatureEnabled(FeatureFlag.EnableExtensions)) { |
| return; |
| } |
| const response = await SupersetClient.get({ |
| endpoint: '/api/v1/extensions/', |
| }); |
| const extensions: core.Extension[] = response.json.result; |
| await Promise.all( |
| extensions.map(async extension => { |
| await this.initializeExtension(extension); |
| }), |
| ); |
| } |
| |
| /** |
| * Initializes an extension by its instance. |
| * If the extension has a remote entry, it will load the module. |
| * @param extension The extension to initialize. |
| */ |
| public async initializeExtension(extension: core.Extension) { |
| try { |
| let loadedExtension = extension; |
| if (extension.remoteEntry) { |
| loadedExtension = await this.loadModule(extension); |
| this.enableExtension(loadedExtension); |
| } |
| this.extensionIndex.set(loadedExtension.id, loadedExtension); |
| } catch (error) { |
| logging.error( |
| `Failed to initialize extension ${extension.name}\n`, |
| error, |
| ); |
| } |
| } |
| |
| /** |
| * Enables an extension by its instance. |
| * @param extension The extension to enable. |
| */ |
| private enableExtension(extension: core.Extension): void { |
| const { id } = extension; |
| if (extension && typeof extension.activate === 'function') { |
| // If already enabled, do nothing |
| if (this.contextIndex.has(id)) { |
| return; |
| } |
| const context = new ExtensionContext(); |
| this.contextIndex.set(id, context); |
| // TODO: Activate based on activation events |
| this.activateExtension(extension, context); |
| this.indexContributions(extension); |
| } |
| } |
| |
| /** |
| * Loads a single extension module. |
| * @param extension The extension to load. |
| * @returns The loaded extension with activate and deactivate methods. |
| */ |
| private async loadModule(extension: core.Extension): Promise<core.Extension> { |
| const { remoteEntry, id, exposedModules } = extension; |
| |
| // Load the remote entry script |
| await new Promise<void>((resolve, reject) => { |
| const element = document.createElement('script'); |
| element.src = remoteEntry; |
| element.type = 'text/javascript'; |
| element.async = true; |
| element.onload = () => resolve(); |
| element.onerror = ( |
| event: Event | string, |
| source?: string, |
| lineno?: number, |
| colno?: number, |
| error?: Error, |
| ) => { |
| const errorDetails = []; |
| if (source) errorDetails.push(`source: ${source}`); |
| if (lineno !== undefined) errorDetails.push(`line: ${lineno}`); |
| if (colno !== undefined) errorDetails.push(`column: ${colno}`); |
| if (error?.message) errorDetails.push(`error: ${error.message}`); |
| if (typeof event === 'string') errorDetails.push(`event: ${event}`); |
| |
| const detailsStr = |
| errorDetails.length > 0 ? `\n${errorDetails.join(', ')}` : ''; |
| const errorMessage = `Failed to load remote entry: ${remoteEntry}${detailsStr}`; |
| |
| return reject(new Error(errorMessage)); |
| }; |
| |
| document.head.appendChild(element); |
| }); |
| |
| // Initialize Webpack module federation |
| // @ts-ignore |
| await __webpack_init_sharing__('default'); |
| const container = (window as any)[id]; |
| |
| // @ts-ignore |
| await container.init(__webpack_share_scopes__.default); |
| |
| const factory = await container.get(exposedModules[0]); |
| const Module = factory(); |
| return { |
| ...extension, |
| activate: Module.activate, |
| deactivate: Module.deactivate, |
| }; |
| } |
| |
| /** |
| * Activates an extension if it has an activate method. |
| * @param extension The extension to activate. |
| * @param context The context to pass to the activate method. |
| */ |
| public activateExtension( |
| extension: core.Extension, |
| context: ExtensionContext, |
| ): void { |
| if (extension.activate) { |
| try { |
| extension.activate(context); |
| } catch (err) { |
| logging.warn(`Error activating ${extension.name}`, err); |
| } |
| } |
| } |
| |
| /** |
| * Deactivates an extension. |
| * @param id The id of the extension to deactivate. |
| * @returns True if deactivated, false otherwise. |
| */ |
| public deactivateExtension(id: string): boolean { |
| const extension = this.extensionIndex.get(id); |
| const context = extension ? this.contextIndex.get(extension.id) : undefined; |
| if (extension && context) { |
| try { |
| // Dispose of all disposables in the context |
| if (context.disposables) { |
| context.disposables.forEach(d => d.dispose()); |
| context.disposables = []; |
| } |
| if (typeof extension.deactivate === 'function') { |
| extension.deactivate(); |
| } |
| return true; |
| } catch (err) { |
| logging.warn(`Error deactivating ${extension.name}`, err); |
| } |
| } |
| return false; |
| } |
| |
| /** |
| * Indexes contributions from an extension for quick retrieval. |
| * @param extension The extension to index. |
| */ |
| private indexContributions(extension: core.Extension): void { |
| const { contributions, id } = extension; |
| this.extensionContributions.set(id, { |
| menus: contributions.menus, |
| views: contributions.views, |
| commands: contributions.commands, |
| }); |
| } |
| |
| /** |
| * Retrieves menu contributions for a specific key. |
| * @param key The key of the menu contributions. |
| * @returns The menu contributions matching the key, or undefined if not found. |
| */ |
| public getMenuContributions( |
| key: string, |
| ): contributions.MenuContribution | undefined { |
| const merged: contributions.MenuContribution = { |
| context: [], |
| primary: [], |
| secondary: [], |
| }; |
| for (const ext of this.extensionContributions.values()) { |
| if (ext.menus && ext.menus[key]) { |
| const menu = ext.menus[key]; |
| if (menu.context) merged.context!.push(...menu.context); |
| if (menu.primary) merged.primary!.push(...menu.primary); |
| if (menu.secondary) merged.secondary!.push(...menu.secondary); |
| } |
| } |
| if ( |
| (merged.context?.length ?? 0) === 0 && |
| (merged.primary?.length ?? 0) === 0 && |
| (merged.secondary?.length ?? 0) === 0 |
| ) { |
| return undefined; |
| } |
| return merged; |
| } |
| |
| /** |
| * Retrieves view contributions for a specific key. |
| * @param key The key of the view contributions. |
| * @returns An array of view contributions matching the key, or undefined if not found. |
| */ |
| public getViewContributions( |
| key: string, |
| ): contributions.ViewContribution[] | undefined { |
| let result: contributions.ViewContribution[] = []; |
| for (const ext of this.extensionContributions.values()) { |
| if (ext.views && ext.views[key]) { |
| result = result.concat(ext.views[key]); |
| } |
| } |
| return result.length > 0 ? result : undefined; |
| } |
| |
| /** |
| * Retrieves all command contributions. |
| * @returns An array of all command contributions. |
| */ |
| public getCommandContributions(): contributions.CommandContribution[] { |
| const result: contributions.CommandContribution[] = []; |
| for (const ext of this.extensionContributions.values()) { |
| if (ext.commands) { |
| result.push(...ext.commands); |
| } |
| } |
| return result; |
| } |
| |
| /** |
| * Retrieves a specific command contribution by its key. |
| * @param key The key of the command contribution. |
| * @returns The command contribution matching the key, or undefined if not found. |
| */ |
| public getCommandContribution( |
| key: string, |
| ): contributions.CommandContribution | undefined { |
| for (const ext of this.extensionContributions.values()) { |
| if (ext.commands) { |
| const found = ext.commands.find(cmd => cmd.command === key); |
| if (found) return found; |
| } |
| } |
| return undefined; |
| } |
| |
| /** |
| * Retrieves all extensions. |
| * @returns An array of all registered extensions. |
| */ |
| public getExtensions(): core.Extension[] { |
| return Array.from(this.extensionIndex.values()); |
| } |
| |
| /** |
| * Retrieves a specific extension by its id. |
| * @param id The id of the extension. |
| * @returns The extension matching the id, or undefined if not found. |
| */ |
| public getExtension(id: string): core.Extension | undefined { |
| return this.extensionIndex.get(id); |
| } |
| } |
| |
| export default ExtensionsManager; |