import path from "node:path"; import { glob } from "glob"; import type Router from "koa-router"; import { isArray, sortBy } from "es-toolkit/compat"; import type BaseEmail from "@server/emails/templates/BaseEmail"; import env from "@server/env"; import Logger from "@server/logging/Logger"; import type BaseProcessor from "@server/queues/processors/BaseProcessor"; import type { BaseTask } from "@server/queues/tasks/base/BaseTask"; import type { UnfurlSignature, UninstallSignature } from "@server/types"; import type { BaseIssueProvider } from "./BaseIssueProvider"; import type { GroupSyncProvider } from "./GroupSyncProvider"; import type { BaseSearchProvider } from "./BaseSearchProvider"; export enum PluginPriority { VeryHigh = 0, High = 100, Normal = 200, Low = 300, VeryLow = 500, } /** * The different types of server plugins that can be registered. */ export enum Hook { API = "api", AuthProvider = "authProvider", EmailTemplate = "emailTemplate", IssueProvider = "issueProvider", Processor = "processor", SearchProvider = "searchProvider", Task = "task", UnfurlProvider = "unfurl", Uninstall = "uninstall", GroupSyncProvider = "groupSyncProvider", } /** * A map of plugin types to their values, for example an API plugin would have a value of type * Router. Registering an API plugin causes the router to be mounted. */ type PluginValueMap = { [Hook.API]: Router; [Hook.AuthProvider]: { router: Router | Promise; id: string }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- typeof BaseEmail isn't assignable from BaseEmail; plugins register heterogeneous template Props. [Hook.EmailTemplate]: typeof BaseEmail; [Hook.IssueProvider]: BaseIssueProvider; [Hook.Processor]: typeof BaseProcessor; [Hook.SearchProvider]: BaseSearchProvider; [Hook.Task]: typeof BaseTask; [Hook.Uninstall]: UninstallSignature; [Hook.UnfurlProvider]: { unfurl: UnfurlSignature; cacheExpiry: number }; [Hook.GroupSyncProvider]: { id: string; provider: GroupSyncProvider }; }; export type Plugin = { /** Plugin type */ type: T; /** The plugin's display name */ name?: string; /** A brief description of the plugin */ description?: string; /** The plugin content */ value: PluginValueMap[T]; /** Priority will affect order in menus and execution. Lower is earlier. */ priority?: number; }; /** * Server plugin manager. */ export class PluginManager { private static plugins = new Map[]>(); /** * Add plugins to the manager. * * @param plugins */ public static add(plugins: Array> | Plugin) { if (isArray(plugins)) { return plugins.forEach((plugin) => this.register(plugin)); } this.register(plugins); } private static register(plugin: Plugin) { if (!this.plugins.has(plugin.type)) { this.plugins.set(plugin.type, []); } this.plugins .get(plugin.type)! .push({ ...plugin, priority: plugin.priority ?? PluginPriority.Normal }); // Do not log plugin registration in forked worker processes, one log from the master process // is enough. This can be detected by the presence of `process.send`. if (process.send === undefined) { Logger.debug( "plugins", `Plugin(type=${plugin.type}) registered ${ "name" in plugin.value ? plugin.value.name : "" } ${plugin.description ? `(${plugin.description})` : ""}` ); } } /** * Returns all the plugins of a given type in order of priority. * Triggers loading of all plugins from disk if not already loaded. * * @param type - the type of plugin to filter by. * @returns a list of plugins. */ public static getHooks(type: T) { this.loadPlugins(); return sortBy(this.plugins.get(type) || [], "priority") as Plugin[]; } /** * Returns the GroupSyncProvider for the given authentication provider name. * * @param name - the authentication provider name (e.g. "oidc", "google"). * @returns the GroupSyncProvider if one is registered, undefined otherwise. */ public static getGroupSyncProvider( name: string ): GroupSyncProvider | undefined { const hooks = this.getHooks(Hook.GroupSyncProvider); return hooks.find((h) => h.value.id === name)?.value.provider; } /** * Load plugin server components (anything in the `/server/` directory of a plugin will be loaded) */ public static loadPlugins() { if (this.loaded) { return; } const rootDir = env.ENVIRONMENT === "test" ? "" : "build"; glob .sync(path.join(rootDir, "plugins/*/server/!(*.test|schema).[jt]s")) .forEach((filePath: string) => require(path.join(process.cwd(), filePath)) ); this.loaded = true; } private static loaded = false; }