blob: 49de8d705b9e5dbfa14f2c408bae22f93df7cd4a [file] [log] [blame]
/**
* 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;