feat(plugin): Initialize new plugin system

This commit is contained in:
Jesse Wierzbinski 2024-06-21 18:22:53 -10:00
parent 1b427cf225
commit 98f8ec071c
No known key found for this signature in database
9 changed files with 307 additions and 1 deletions

View file

@ -5,6 +5,7 @@
"api",
"cli",
"federation",
"config"
"config",
"plugin"
]
}

BIN
bun.lockb

Binary file not shown.

View file

@ -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;
});

View file

@ -0,0 +1,4 @@
export type ServerHooks = {
request: (request: Request) => Request;
response: (response: Response) => Response;
};

View file

@ -0,0 +1,5 @@
import { Plugin } from "./plugin";
import type { Manifest } from "./schema";
export type { Manifest };
export { Plugin };

View file

@ -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"
}
}
}

View file

@ -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<ConfigSchema extends z.ZodTypeAny> {
private handlers: Partial<ServerHooks> = {};
constructor(
private manifest: Manifest,
private configManager: PluginConfigManager<ConfigSchema>,
) {
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<ConfigSchema>) {
// biome-ignore lint/complexity/useLiteralKeys: Private method
this.configManager["_load"](config);
}
public registerHandler<HookName extends keyof ServerHooks>(
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<Schema extends z.ZodTypeAny> {
private store: z.infer<Schema> | 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<Schema>) {
// 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;
}
}

View file

@ -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<A, B> = Exclude<A, B> | Exclude<B, A>;
assert<TypeEqualityGuard<Manifest, z.infer<typeof manifestSchema>>>();

View file

@ -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"`,
);
});
});