From d224d7b9b8a76e7d7efab75f5571997007e57ff4 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Mon, 23 Sep 2024 11:51:15 +0200 Subject: [PATCH] feat(plugin): :sparkles: Add dynamic plugin and manifest loader --- app.ts | 41 ++++- bun.lockb | Bin 286456 -> 286488 bytes classes/plugin/loader.test.ts | 226 +++++++++++++++++++++++++ classes/plugin/loader.ts | 176 +++++++++++++++++++ config/config.schema.json | 4 + package.json | 1 + packages/config-manager/config.type.ts | 1 + packages/plugin-kit/plugin.ts | 6 +- utils/loggers.ts | 5 + 9 files changed, 451 insertions(+), 9 deletions(-) create mode 100644 classes/plugin/loader.test.ts create mode 100644 classes/plugin/loader.ts 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 25f0c3b0ab45bca4ff010043d6cf96edcee50e3b..82cca464275956df9eb3e2d1e2ac96444bc4f5fc 100755 GIT binary patch delta 12959 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 delta 12809 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 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"], + }, ], });