Files
outline/server/utils/PluginManager.ts
T
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00

154 lines
4.8 KiB
TypeScript

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<Router>; id: string };
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- typeof BaseEmail<EmailProps> isn't assignable from BaseEmail<Subtype>; plugins register heterogeneous template Props.
[Hook.EmailTemplate]: typeof BaseEmail<any>;
[Hook.IssueProvider]: BaseIssueProvider;
[Hook.Processor]: typeof BaseProcessor;
[Hook.SearchProvider]: BaseSearchProvider;
[Hook.Task]: typeof BaseTask<object>;
[Hook.Uninstall]: UninstallSignature;
[Hook.UnfurlProvider]: { unfurl: UnfurlSignature; cacheExpiry: number };
[Hook.GroupSyncProvider]: { id: string; provider: GroupSyncProvider };
};
export type Plugin<T extends Hook> = {
/** 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<Hook, Plugin<Hook>[]>();
/**
* Add plugins to the manager.
*
* @param plugins
*/
public static add(plugins: Array<Plugin<Hook>> | Plugin<Hook>) {
if (isArray(plugins)) {
return plugins.forEach((plugin) => this.register(plugin));
}
this.register(plugins);
}
private static register<T extends Hook>(plugin: Plugin<T>) {
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<T extends Hook>(type: T) {
this.loadPlugins();
return sortBy(this.plugins.get(type) || [], "priority") as Plugin<T>[];
}
/**
* 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;
}