diff --git a/.vscode/settings.json b/.vscode/settings.json index 7ea29ad6..76db3e48 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "api", "cli", "federation", - "config" + "config", + "plugin" ] } diff --git a/bun.lockb b/bun.lockb index d56e392b..b913705c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/plugin-kit/example.ts b/packages/plugin-kit/example.ts new file mode 100644 index 00000000..58e9e135 --- /dev/null +++ b/packages/plugin-kit/example.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; +import { Plugin, PluginConfigManager } from "./plugin"; +import type { Manifest } from "./schema"; + +const myManifest: Manifest = { + name: "my-plugin", + description: "A plugin for my app", + version: "1.0.0", +}; +const configManager = new PluginConfigManager( + z.object({ + apiKey: z.string(), + }), +); +const myPlugin = new Plugin(myManifest, configManager); + +myPlugin.registerHandler("request", (req) => { + console.info("Request received:", req); + return req; +}); diff --git a/packages/plugin-kit/hooks.ts b/packages/plugin-kit/hooks.ts new file mode 100644 index 00000000..0be21039 --- /dev/null +++ b/packages/plugin-kit/hooks.ts @@ -0,0 +1,4 @@ +export type ServerHooks = { + request: (request: Request) => Request; + response: (response: Response) => Response; +}; diff --git a/packages/plugin-kit/index.ts b/packages/plugin-kit/index.ts new file mode 100644 index 00000000..06a6774e --- /dev/null +++ b/packages/plugin-kit/index.ts @@ -0,0 +1,5 @@ +import { Plugin } from "./plugin"; +import type { Manifest } from "./schema"; + +export type { Manifest }; +export { Plugin }; diff --git a/packages/plugin-kit/package.json b/packages/plugin-kit/package.json new file mode 100644 index 00000000..ff5554bd --- /dev/null +++ b/packages/plugin-kit/package.json @@ -0,0 +1,41 @@ +{ + "name": "@lysand-org/kit", + "module": "index.ts", + "type": "module", + "version": "0.0.0", + "description": "Framework for building Lysand Server plugins", + "author": { + "email": "contact@cpluspatch.com", + "name": "CPlusPatch", + "url": "https://cpluspatch.com" + }, + "bugs": { + "url": "https://github.com/lysand-org/lysand/issues" + }, + "icon": "https://github.com/lysand-org/lysand", + "license": "AGPL-3.0-or-later", + "keywords": ["federated", "activitypub", "bun"], + "maintainers": [ + { + "email": "contact@cpluspatch.com", + "name": "CPlusPatch", + "url": "https://cpluspatch.com" + } + ], + "repository": { + "type": "git", + "url": "git+https://github.com/lysand-org/lysand.git" + }, + "private": true, + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.23.8", + "zod-validation-error": "^3.3.0" + }, + "exports": { + ".": { + "import": "./index.ts", + "default": "./index.ts" + } + } +} diff --git a/packages/plugin-kit/plugin.ts b/packages/plugin-kit/plugin.ts new file mode 100644 index 00000000..c40c17a9 --- /dev/null +++ b/packages/plugin-kit/plugin.ts @@ -0,0 +1,87 @@ +import type { z } from "zod"; +import { type ZodError, fromZodError } from "zod-validation-error"; +import type { ServerHooks } from "./hooks"; +import { type Manifest, manifestSchema } from "./schema"; + +export class Plugin { + private handlers: Partial = {}; + + constructor( + private manifest: Manifest, + private configManager: PluginConfigManager, + ) { + this.validateManifest(manifest); + } + + public getManifest() { + return this.manifest; + } + + /** + * Loads the plugin's configuration from the Lysand Server configuration file. + * This will be called when the plugin is loaded. + * @param config Values the user has set in the configuration file. + */ + protected _loadConfig(config: z.infer) { + // biome-ignore lint/complexity/useLiteralKeys: Private method + this.configManager["_load"](config); + } + + public registerHandler( + hook: HookName, + handler: ServerHooks[HookName], + ) { + this.handlers[hook] = handler; + } + + private validateManifest(manifest: Manifest) { + try { + manifestSchema.parse(manifest); + } catch (error) { + throw fromZodError(error as ZodError); + } + } + + static [Symbol.hasInstance](instance: unknown): boolean { + return ( + typeof instance === "object" && + instance !== null && + "getManifest" in instance && + "registerHandler" in instance + ); + } +} + +/** + * Handles loading, defining, and managing the plugin's configuration. + * Plugins can define their own configuration schema, which is then used to + * load it from the user's configuration file. + */ +export class PluginConfigManager { + private store: z.infer | null; + + constructor(private schema: Schema) { + this.store = null; + } + + /** + * Loads the configuration from the Lysand Server configuration file. + * This will be called when the plugin is loaded. + * @param config Values the user has set in the configuration file. + */ + protected _load(config: z.infer) { + // Check if the configuration is valid + try { + this.schema.parse(config); + } catch (error) { + throw fromZodError(error as ZodError); + } + } + + /** + * Returns the internal configuration object. + */ + public getConfig() { + return this.store; + } +} diff --git a/packages/plugin-kit/schema.ts b/packages/plugin-kit/schema.ts new file mode 100644 index 00000000..0a30274c --- /dev/null +++ b/packages/plugin-kit/schema.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; + +export const manifestSchema = z.object({ + name: z.string().min(3).max(100), + version: z + .string() + .regex( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm, + "Version must be valid SemVer string", + ), + description: z.string().min(1).max(4096), + authors: z + .array( + z.object({ + name: z.string().min(1).max(100), + email: z.string().email().optional(), + url: z.string().url().optional(), + }), + ) + .optional(), + repository: z + .object({ + type: z + .enum([ + "git", + "svn", + "mercurial", + "bzr", + "darcs", + "mtn", + "cvs", + "fossil", + "bazaar", + "arch", + "tla", + "archie", + "monotone", + "perforce", + "sourcevault", + "plastic", + "clearcase", + "accurev", + "surroundscm", + "bitkeeper", + "other", + ]) + .optional(), + url: z.string().url().optional(), + }) + .optional(), +}); + +export type Manifest = { + name: string; + version: string; + description: string; + authors?: + | { + name: string; + email?: string | undefined; + url?: string | undefined; + }[] + | undefined; + repository?: + | { + type?: + | "git" + | "svn" + | "mercurial" + | "bzr" + | "darcs" + | "mtn" + | "cvs" + | "fossil" + | "bazaar" + | "arch" + | "tla" + | "archie" + | "monotone" + | "perforce" + | "sourcevault" + | "plastic" + | "clearcase" + | "accurev" + | "surroundscm" + | "bitkeeper" + | "other" + | undefined; + url?: string | undefined; + } + | undefined; +}; + +// This is a type guard to ensure that the schema and the type are in sync +function assert<_T extends never>() { + // ... +} +type TypeEqualityGuard = Exclude | Exclude; +assert>>(); diff --git a/packages/plugin-kit/tests/manifest.test.ts b/packages/plugin-kit/tests/manifest.test.ts new file mode 100644 index 00000000..5ab888cd --- /dev/null +++ b/packages/plugin-kit/tests/manifest.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import { z } from "zod"; +import { Plugin, PluginConfigManager } from "../plugin"; +import type { Manifest } from "../schema"; + +describe("Manifest parsing tests", () => { + it("should parse a valid manifest", () => { + const manifest: Manifest = { + name: "plugin", + version: "1.0.0", + description: "A test plugin", + authors: [ + { + name: "Author", + email: "bob@joe.com", + url: "https://example.com", + }, + ], + repository: { + type: "git", + url: "https://example.com", + }, + }; + + const plugin = new Plugin( + manifest, + new PluginConfigManager(z.string()), + ); + + expect(plugin.getManifest()).toEqual(manifest); + }); + + it("should throw an error for an invalid manifest", () => { + const manifest = { + name: "plugin", + silly: "Manifest", + }; + + expect( + () => + new Plugin( + manifest as unknown as Manifest, + new PluginConfigManager(z.string()), + ), + ).toThrowError( + `Validation error: Required at "version"; Required at "description"`, + ); + }); +});