blob: b62d3f59d072b30d3995f1de7fff0571cd0a9dde [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 { RefObject } from 'react';
import builtin from '@/plugins/builtin';
import * as allPlugins from '@/plugins';
import type * as Type from '@/common/interface';
import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants';
import { getPluginsStatus } from '@/services';
import Storage from '@/utils/storage';
import request from '@/utils/request';
import { initI18nResource } from './utils';
import { Plugin, PluginInfo, PluginType } from './interface';
/**
* This information is to be defined for all components.
* It may be used for feature upgrades or version compatibility processing.
*
* @field slug_name: Unique identity string for the plugin, usually configured in `info.yaml`
* @field type: The type of plugin is defined and a single type of plugin can have multiple implementations.
* For example, a plugin of type `connector` can have a `google` implementation and a `github` implementation.
* `PluginRender` automatically renders the plug-in types already included in `PluginType`.
* @field name: Plugin name, optionally configurable. Usually read from the `i18n` file
* @field description: Plugin description, optionally configurable. Usually read from the `i18n` file
*/
class Plugins {
plugins: Plugin[] = [];
registeredPlugins: Type.ActivatedPlugin[] = [];
initialization: Promise<void>;
private isInitialized = false;
private initializationError: Error | null = null;
private replacementPlugins: Map<PluginType, Plugin> = new Map();
constructor() {
this.initialization = this.init();
}
async init() {
if (this.isInitialized) {
return;
}
try {
this.registerBuiltin();
// Note: The /install stage does not allow access to the getPluginsStatus api, so an initial value needs to be given
const plugins =
(await getPluginsStatus().catch((error) => {
console.warn('Failed to get plugins status:', error);
return [];
})) || [];
this.registeredPlugins = plugins.filter((p) => p.enabled);
await this.registerPlugins();
this.isInitialized = true;
this.initializationError = null;
} catch (error) {
this.initializationError = error as Error;
console.error('Plugin initialization failed:', error);
throw error;
}
}
async refresh() {
this.plugins = [];
this.isInitialized = false;
this.initializationError = null;
this.initialization = this.init();
await this.initialization;
}
validate(plugin: Plugin) {
if (!plugin) {
return false;
}
const { info } = plugin;
const { slug_name, type } = info;
if (!slug_name) {
return false;
}
if (!type) {
return false;
}
return true;
}
registerBuiltin() {
Object.keys(builtin).forEach((key) => {
const plugin = builtin[key];
// builttin plugins are always activated
// Use own internal rendering logic'
plugin.activated = true;
this.register(plugin);
});
}
async registerPlugins() {
console.log(
'[PluginKit] Registered plugins from API:',
this.registeredPlugins.map((p) => p.slug_name),
);
const pluginLoaders = this.registeredPlugins
.map((p) => {
const func = allPlugins[p.slug_name];
if (!func) {
console.warn(
`[PluginKit] Plugin loader not found for: ${p.slug_name}`,
);
}
return { slug_name: p.slug_name, loader: func };
})
.filter((p) => p.loader);
console.log(
'[PluginKit] Found plugin loaders:',
pluginLoaders.map((p) => p.slug_name),
);
// Use Promise.allSettled to prevent one plugin failure from breaking all plugins
const results = await Promise.allSettled(
pluginLoaders.map((p) => p.loader()),
);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(
`[PluginKit] Successfully loaded plugin: ${pluginLoaders[index].slug_name}`,
);
this.register(result.value);
} else {
console.error(
`[PluginKit] Failed to load plugin ${pluginLoaders[index].slug_name}:`,
result.reason,
);
}
});
}
register(plugin: Plugin) {
const bool = this.validate(plugin);
if (!bool) {
return;
}
// Prevent duplicate registration
const exists = this.plugins.some(
(p) => p.info.slug_name === plugin.info.slug_name,
);
if (exists) {
console.warn(`Plugin ${plugin.info.slug_name} is already registered`);
return;
}
// Handle singleton plugins (only one per type allowed)
const mode = plugin.info.registrationMode || 'multiple';
if (mode === 'singleton') {
const existingPlugin = this.replacementPlugins.get(plugin.info.type);
if (existingPlugin) {
const error = new Error(
`[PluginKit] Plugin conflict: ` +
`Cannot register '${plugin.info.slug_name}' because '${existingPlugin.info.slug_name}' ` +
`is already registered as a singleton plugin of type '${plugin.info.type}'. ` +
`Only one singleton plugin per type is allowed.`,
);
console.error(error.message);
throw error;
}
this.replacementPlugins.set(plugin.info.type, plugin);
}
if (plugin.i18nConfig) {
initI18nResource(plugin.i18nConfig);
}
plugin.activated = true;
this.plugins.push(plugin);
}
getPlugin(slug_name: string) {
return this.plugins.find((p) => p.info.slug_name === slug_name);
}
getOnePluginHooks(slug_name: string) {
const plugin = this.getPlugin(slug_name);
return plugin?.hooks;
}
getPlugins() {
return this.plugins;
}
async getPluginsAsync() {
await this.initialization;
return this.plugins;
}
getInitializationStatus() {
return {
isInitialized: this.isInitialized,
error: this.initializationError,
};
}
getReplacementPlugin(type: PluginType): Plugin | null {
return this.replacementPlugins.get(type) || null;
}
}
const plugins = new Plugins();
const getRoutePlugins = async () => {
await plugins.initialization;
return plugins
.getPlugins()
.filter((plugin) => plugin.info.type === PluginType.Route);
};
const defaultProps = () => {
const token = Storage.get(LOGGED_TOKEN_STORAGE_KEY) || '';
return {
key: token,
headers: {
Authorization: token,
},
};
};
const validateRoutePlugin = async (slugName) => {
let registeredPlugin;
if (plugins.registeredPlugins.length === 0) {
const pluginsStatus = await getPluginsStatus();
registeredPlugin = pluginsStatus.find((p) => p.slug_name === slugName);
} else {
registeredPlugin = plugins.registeredPlugins.find(
(p) => p.slug_name === slugName,
);
}
return Boolean(registeredPlugin?.enabled);
};
const getReplacementPlugin = async (
type: PluginType,
): Promise<Plugin | null> => {
try {
await plugins.initialization;
return plugins.getReplacementPlugin(type);
} catch (error) {
console.error(
`[PluginKit] Failed to get replacement plugin of type ${type}:`,
error,
);
return null;
}
};
const mergeRoutePlugins = async (routes) => {
const routePlugins = await getRoutePlugins();
if (routePlugins.length === 0) {
return routes;
}
routes.forEach((route) => {
if (route.page === 'pages/Layout') {
route.children?.forEach((child) => {
if (child.page === 'pages/SideNavLayout') {
routePlugins.forEach((plugin) => {
const { route: path, slug_name } = plugin.info;
child.children.push({
page: plugin.component,
path,
loader: async () => {
const bool = await validateRoutePlugin(slug_name);
return bool;
},
guard: (params) => {
if (params.loaderData) {
return {
ok: true,
};
}
return {
ok: false,
error: {
code: 404,
},
};
},
});
});
}
});
}
});
return routes;
};
/**
* Only used to enhance the capabilities of the markdown editor
* Add RefObject type to solve the problem of dom being null in hooks
*/
const useRenderHtmlPlugin = (
element: HTMLElement | RefObject<HTMLElement> | null,
) => {
plugins
.getPlugins()
.filter((plugin) => {
return (
plugin.activated &&
plugin.hooks?.useRender &&
(plugin.info.type === PluginType.Editor ||
plugin.info.type === PluginType.Render)
);
})
.forEach((plugin) => {
plugin.hooks?.useRender?.forEach((hook) => {
hook(element, request);
});
});
};
// Only for render type plugins
const useRenderPlugin = (
element: HTMLElement | RefObject<HTMLElement> | null,
) => {
return plugins
.getPlugins()
.filter((plugin) => {
return (
plugin.activated &&
plugin.hooks?.useRender &&
plugin.info.type === PluginType.Render
);
})
.forEach((plugin) => {
plugin.hooks?.useRender?.forEach((hook) => {
hook(element, request);
});
});
};
// Only one captcha type plug-in can be enabled at the same time
const useCaptchaPlugin = (key: Type.CaptchaKey) => {
const captcha = plugins
.getPlugins()
.filter(
(plugin) => plugin.info.type === PluginType.Captcha && plugin.activated,
);
const pluginHooks = plugins.getOnePluginHooks(captcha[0]?.info.slug_name);
return pluginHooks?.useCaptcha?.({
captchaKey: key,
commonProps: defaultProps(),
});
};
export type { Plugin, PluginInfo };
export {
useRenderHtmlPlugin,
mergeRoutePlugins,
useCaptchaPlugin,
useRenderPlugin,
getReplacementPlugin,
PluginType,
};
export default plugins;