diff --git a/app.ts b/app.ts index 0c4f713c..25129027 100644 --- a/app.ts +++ b/app.ts @@ -1,4 +1,6 @@ +import { join } from "node:path"; import { handleZodError } from "@/api"; +import { configureLoggers } from "@/loggers"; import { sentry } from "@/sentry"; import { cors } from "@hono/hono/cors"; import { createMiddleware } from "@hono/hono/factory"; @@ -8,9 +10,11 @@ import { swaggerUI } from "@hono/swagger-ui"; import { OpenAPIHono } from "@hono/zod-openapi"; /* import { prometheus } from "@hono/prometheus"; */ import { getLogger } from "@logtape/logtape"; +import chalk from "chalk"; +import type { ValidationError } from "zod-validation-error"; import pkg from "~/package.json" with { type: "application/json" }; import { config } from "~/packages/config-manager/index"; -import plugin from "~/plugins/openid"; +import { PluginLoader } from "./classes/plugin/loader"; import { agentBans } from "./middlewares/agent-bans"; import { bait } from "./middlewares/bait"; import { boundaryCheck } from "./middlewares/boundary-check"; @@ -20,6 +24,7 @@ import { routes } from "./routes"; import type { ApiRouteExports, HonoEnv } from "./types/api"; export const appFactory = async () => { + await configureLoggers(); const serverLogger = getLogger("server"); const app = new OpenAPIHono({ @@ -110,11 +115,35 @@ export const appFactory = async () => { route.default(app); } - // @ts-expect-error We check if the keys are valid before this is called - // biome-ignore lint/complexity/useLiteralKeys: loadConfig is a private method - plugin["_loadConfig"](config.oidc); - // biome-ignore lint/complexity/useLiteralKeys: AddToApp is a private method - plugin["_addToApp"](app); + serverLogger.info`Loading plugins`; + + const loader = new PluginLoader(); + + const plugins = await loader.loadPlugins(join(process.cwd(), "plugins")); + + for (const data of plugins) { + serverLogger.info`Loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} ${chalk.gray(`[${plugins.indexOf(data) + 1}/${plugins.length}]`)}`; + try { + // biome-ignore lint/complexity/useLiteralKeys: loadConfig is a private method + await data.plugin["_loadConfig"]( + config.plugins?.[data.manifest.name], + ); + } catch (e) { + serverLogger.fatal`Plugin configuration is invalid: ${chalk.redBright(e as ValidationError)}`; + serverLogger.fatal`Put your configuration at ${chalk.blueBright( + "plugins.", + )}`; + serverLogger.fatal`Press Ctrl+C to exit`; + + // Hang until Ctrl+C is pressed + await Bun.sleep(Number.POSITIVE_INFINITY); + process.exit(); + } + // biome-ignore lint/complexity/useLiteralKeys: AddToApp is a private method + await data.plugin["_addToApp"](app); + } + + serverLogger.info`Plugins loaded`; app.doc31("/openapi.json", { openapi: "3.1.0", diff --git a/bun.lockb b/bun.lockb index 25f0c3b0..82cca464 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/classes/plugin/loader.test.ts b/classes/plugin/loader.test.ts new file mode 100644 index 00000000..8e8fc95f --- /dev/null +++ b/classes/plugin/loader.test.ts @@ -0,0 +1,226 @@ +import { + afterEach, + beforeEach, + describe, + expect, + jest, + mock, + test, +} from "bun:test"; +import { ZodError, type ZodTypeAny, z } from "zod"; +import { Plugin, PluginConfigManager } from "~/packages/plugin-kit"; +import { type Manifest, manifestSchema } from "~/packages/plugin-kit/schema"; +import { PluginLoader } from "./loader"; + +const mockReaddir = jest.fn(); +const mockGetLogger = jest.fn(() => ({ + fatal: jest.fn(), +})); +const mockParseJSON5 = jest.fn(); +const mockParseJSONC = jest.fn(); +const mockFromZodError = jest.fn(); + +mock.module("node:fs/promises", () => ({ + readdir: mockReaddir, +})); + +mock.module("@logtape/logtape", () => ({ + getLogger: mockGetLogger, +})); + +mock.module("confbox", () => ({ + parseJSON5: mockParseJSON5, + parseJSONC: mockParseJSONC, +})); + +mock.module("zod-validation-error", () => ({ + fromZodError: mockFromZodError, +})); + +describe("PluginLoader", () => { + let pluginLoader: PluginLoader; + + beforeEach(() => { + pluginLoader = new PluginLoader(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test("getDirectories should return directories", async () => { + mockReaddir.mockResolvedValue([ + { name: "dir1", isDirectory: () => true }, + { name: "file1", isDirectory: () => false }, + { name: "dir2", isDirectory: () => true }, + ]); + + // biome-ignore lint/complexity/useLiteralKeys: Private method + const directories = await pluginLoader["getDirectories"]("/some/path"); + expect(directories).toEqual(["dir1", "dir2"]); + }); + + test("findManifestFile should return manifest file if found", async () => { + mockReaddir.mockResolvedValue(["manifest.json", "otherfile.txt"]); + + const manifestFile = + // biome-ignore lint/complexity/useLiteralKeys: Private method + await pluginLoader["findManifestFile"]("/some/path"); + expect(manifestFile).toBe("manifest.json"); + }); + + test("hasEntrypoint should return true if entrypoint file is found", async () => { + mockReaddir.mockResolvedValue(["index.ts", "otherfile.txt"]); + + // biome-ignore lint/complexity/useLiteralKeys: Private method + const hasEntrypoint = await pluginLoader["hasEntrypoint"]("/some/path"); + expect(hasEntrypoint).toBe(true); + }); + + test("parseManifestFile should parse JSON manifest", async () => { + const manifestContent = { name: "test-plugin" }; + Bun.file = jest.fn().mockReturnValue({ + text: async () => JSON.stringify(manifestContent), + }); + + // biome-ignore lint/complexity/useLiteralKeys: Private method + const manifest = await pluginLoader["parseManifestFile"]( + "/some/path/manifest.json", + "manifest.json", + ); + expect(manifest).toEqual(manifestContent); + }); + + test("findPlugins should return plugin directories with valid manifest and entrypoint", async () => { + mockReaddir + .mockResolvedValueOnce([ + { name: "plugin1", isDirectory: () => true }, + { name: "plugin2", isDirectory: () => true }, + ]) + .mockResolvedValue(["manifest.json", "index.ts"]); + + const plugins = await pluginLoader.findPlugins("/some/path"); + expect(plugins).toEqual(["plugin1", "plugin2"]); + }); + + test("parseManifest should parse and validate manifest", async () => { + const manifestContent: Manifest = { + name: "test-plugin", + version: "1.1.0", + description: "Doobaee", + }; + mockReaddir.mockResolvedValue(["manifest.json"]); + Bun.file = jest.fn().mockReturnValue({ + text: async () => JSON.stringify(manifestContent), + }); + manifestSchema.safeParseAsync = jest.fn().mockResolvedValue({ + success: true, + data: manifestContent, + }); + + const manifest = await pluginLoader.parseManifest( + "/some/path", + "plugin1", + ); + expect(manifest).toEqual(manifestContent); + }); + + test("parseManifest should throw error if manifest is missing", async () => { + mockReaddir.mockResolvedValue([]); + + await expect( + pluginLoader.parseManifest("/some/path", "plugin1"), + ).rejects.toThrow("Plugin plugin1 is missing a manifest file"); + }); + + test("parseManifest should throw error if manifest is invalid", async () => { + // @ts-expect-error trying to cause a type error here + const manifestContent: Manifest = { + name: "test-plugin", + version: "1.1.0", + }; + mockReaddir.mockResolvedValue(["manifest.json"]); + Bun.file = jest.fn().mockReturnValue({ + text: async () => JSON.stringify(manifestContent), + }); + manifestSchema.safeParseAsync = jest.fn().mockResolvedValue({ + success: false, + error: new ZodError([]), + }); + + await expect( + pluginLoader.parseManifest("/some/path", "plugin1"), + ).rejects.toThrow(); + }); + + test("loadPlugin should load and return a Plugin instance", async () => { + const mockPlugin = new Plugin( + { + name: "test-plugin", + version: "1.1.0", + description: "Doobaee", + }, + new PluginConfigManager(z.object({})), + ); + mock.module("/some/path/index.ts", () => ({ + default: mockPlugin, + })); + + const plugin = await pluginLoader.loadPlugin("/some/path", "index.ts"); + expect(plugin).toBeInstanceOf(Plugin); + }); + + test("loadPlugin should throw error if default export is not a Plugin", async () => { + mock.module("/some/path/index.ts", () => ({ + default: "cheese", + })); + + await expect( + pluginLoader.loadPlugin("/some/path", "index.ts"), + ).rejects.toThrow("Entrypoint is not a Plugin"); + }); + + test("loadPlugins should load all plugins in a directory", async () => { + const manifestContent: Manifest = { + name: "test-plugin", + version: "1.1.0", + description: "Doobaee", + }; + const mockPlugin = new Plugin( + manifestContent, + new PluginConfigManager(z.object({})), + ); + + mockReaddir + .mockResolvedValueOnce([ + { name: "plugin1", isDirectory: () => true }, + { name: "plugin2", isDirectory: () => true }, + ]) + .mockResolvedValue(["manifest.json", "index.ts"]); + Bun.file = jest.fn().mockReturnValue({ + text: async () => JSON.stringify(manifestContent), + }); + manifestSchema.safeParseAsync = jest.fn().mockResolvedValue({ + success: true, + data: manifestContent, + }); + mock.module("/some/path/plugin1/index.ts", () => ({ + default: mockPlugin, + })); + mock.module("/some/path/plugin2/index.ts", () => ({ + default: mockPlugin, + })); + + const plugins = await pluginLoader.loadPlugins("/some/path"); + expect(plugins).toEqual([ + { + manifest: manifestContent, + plugin: mockPlugin as unknown as Plugin, + }, + { + manifest: manifestContent, + plugin: mockPlugin as unknown as Plugin, + }, + ]); + }); +}); diff --git a/classes/plugin/loader.ts b/classes/plugin/loader.ts new file mode 100644 index 00000000..548805ef --- /dev/null +++ b/classes/plugin/loader.ts @@ -0,0 +1,176 @@ +import { readdir } from "node:fs/promises"; +import { getLogger } from "@logtape/logtape"; +import chalk from "chalk"; +import { parseJSON5, parseJSONC } from "confbox"; +import type { ZodTypeAny } from "zod"; +import { fromZodError } from "zod-validation-error"; +import { Plugin } from "~/packages/plugin-kit/plugin"; +import { type Manifest, manifestSchema } from "~/packages/plugin-kit/schema"; + +/** + * Class to manage plugins. + */ +export class PluginLoader { + private logger = getLogger("plugin"); + + /** + * Get all directories in a given directory. + * @param {string} dir - The directory to search. + * @returns {Promise} - An array of directory names. + */ + private async getDirectories(dir: string): Promise { + const files = await readdir(dir, { withFileTypes: true }); + return files.filter((f) => f.isDirectory()).map((f) => f.name); + } + + /** + * Find the manifest file in a given directory. + * @param {string} dir - The directory to search. + * @returns {Promise} - The manifest file name if found, otherwise undefined. + */ + private async findManifestFile(dir: string): Promise { + const files = await readdir(dir); + return files.find((f) => f.match(/^manifest\.(json|json5|jsonc)$/)); + } + + /** + * Check if a directory has an entrypoint file (index.ts). + * @param {string} dir - The directory to search. + * @returns {Promise} - True if the entrypoint file is found, otherwise false. + */ + private async hasEntrypoint(dir: string): Promise { + const files = await readdir(dir); + return files.includes("index.ts"); + } + + /** + * Parse the manifest file based on its type. + * @param {string} manifestPath - The path to the manifest file. + * @param {string} manifestFile - The manifest file name. + * @returns {Promise} - The parsed manifest content. + * @throws Will throw an error if the manifest file cannot be parsed. + */ + private async parseManifestFile( + manifestPath: string, + manifestFile: string, + ): Promise { + const manifestText = await Bun.file(manifestPath).text(); + + try { + if (manifestFile.endsWith(".json")) { + return JSON.parse(manifestText); + } + if (manifestFile.endsWith(".json5")) { + return parseJSON5(manifestText); + } + if (manifestFile.endsWith(".jsonc")) { + return parseJSONC(manifestText); + } + } catch (e) { + this.logger + .fatal`Could not parse plugin manifest ${chalk.blue(manifestPath)} as ${manifestFile.split(".").pop()?.toUpperCase()}.`; + throw e; + } + } + + /** + * Find all direct subdirectories with a valid manifest file and entrypoint (index.ts). + * @param {string} dir - The directory to search. + * @returns {Promise} - An array of plugin directories. + */ + public async findPlugins(dir: string): Promise { + const directories = await this.getDirectories(dir); + const plugins: string[] = []; + + for (const directory of directories) { + const manifestFile = await this.findManifestFile( + `${dir}/${directory}`, + ); + if ( + manifestFile && + (await this.hasEntrypoint(`${dir}/${directory}`)) + ) { + plugins.push(directory); + } + } + + return plugins; + } + + /** + * Parse the manifest file of a plugin. + * @param {string} dir - The directory containing the plugin. + * @param {string} plugin - The plugin directory name. + * @returns {Promise} - The parsed manifest object. + * @throws Will throw an error if the manifest file is missing or invalid. + */ + public async parseManifest(dir: string, plugin: string): Promise { + const manifestFile = await this.findManifestFile(`${dir}/${plugin}`); + + if (!manifestFile) { + throw new Error(`Plugin ${plugin} is missing a manifest file`); + } + + const manifestPath = `${dir}/${plugin}/${manifestFile}`; + const manifest = await this.parseManifestFile( + manifestPath, + manifestFile, + ); + + const result = await manifestSchema.safeParseAsync(manifest); + + if (!result.success) { + this.logger + .fatal`Plugin manifest ${chalk.blue(manifestPath)} is invalid.`; + throw fromZodError(result.error); + } + + return result.data; + } + + /** + * Loads an entrypoint's default export and check if it's a Plugin. + * @param {string} dir - The directory containing the entrypoint. + * @param {string} entrypoint - The entrypoint file name. + * @returns {Promise>} - The loaded Plugin instance. + * @throws Will throw an error if the entrypoint's default export is not a Plugin. + */ + public async loadPlugin( + dir: string, + entrypoint: string, + ): Promise> { + const plugin = (await import(`${dir}/${entrypoint}`)).default; + + if (plugin instanceof Plugin) { + return plugin; + } + + this.logger + .fatal`Default export of entrypoint ${chalk.blue(entrypoint)} at ${chalk.blue(dir)} is not a Plugin.`; + + throw new Error("Entrypoint is not a Plugin"); + } + + /** + * Load all plugins in a given directory. + * @param {string} dir - The directory to search. + * @returns An array of objects containing the manifest and plugin instance. + */ + public async loadPlugins( + dir: string, + ): Promise<{ manifest: Manifest; plugin: Plugin }[]> { + const plugins = await this.findPlugins(dir); + + return Promise.all( + plugins.map(async (plugin) => { + const manifest = await this.parseManifest(dir, plugin); + const pluginInstance = await this.loadPlugin( + dir, + `${plugin}/index.ts`, + ); + + return { manifest, plugin: pluginInstance }; + }), + ); + } +} diff --git a/config/config.schema.json b/config/config.schema.json index 2fb6a6f4..878bf59b 100644 --- a/config/config.schema.json +++ b/config/config.schema.json @@ -4006,6 +4006,10 @@ "default": { "federation": false } + }, + "plugins": { + "type": "object", + "additionalProperties": {} } }, "required": [ diff --git a/package.json b/package.json index 1ec93678..f8ee3abb 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "chalk": "^5.3.0", "cli-progress": "^3.12.0", "cli-table": "^0.3.11", + "confbox": "^0.1.7", "drizzle-orm": "^0.33.0", "extract-zip": "^2.0.1", "hono": "npm:@jsr/hono__hono@4.6.2", diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index 02629358..0e4877e1 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -642,6 +642,7 @@ export const configValidator = z.object({ .default({ federation: false, }), + plugins: z.record(z.string(), z.any()).optional(), }); export type Config = z.infer; diff --git a/packages/plugin-kit/plugin.ts b/packages/plugin-kit/plugin.ts index 4aa1604a..cde98bc7 100644 --- a/packages/plugin-kit/plugin.ts +++ b/packages/plugin-kit/plugin.ts @@ -55,9 +55,9 @@ export class Plugin { * This will be called when the plugin is loaded. * @param config Values the user has set in the configuration file. */ - protected _loadConfig(config: z.input) { + protected _loadConfig(config: z.input): Promise { // biome-ignore lint/complexity/useLiteralKeys: Private method - this.configManager["_load"](config); + return this.configManager["_load"](config); } protected _addToApp(app: OpenAPIHono) { @@ -117,7 +117,7 @@ export class PluginConfigManager { try { this.store = await this.schema.parseAsync(config); } catch (error) { - throw fromZodError(error as ZodError); + throw fromZodError(error as ZodError).message; } } diff --git a/utils/loggers.ts b/utils/loggers.ts index 9139d2d7..d8596aee 100644 --- a/utils/loggers.ts +++ b/utils/loggers.ts @@ -201,5 +201,10 @@ export const configureLoggers = (silent = false) => category: ["logtape", "meta"], level: "error", }, + { + category: "plugin", + sinks: ["console", "file"], + filters: ["configFilter"], + }, ], });