mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(plugin): ✨ Initialize new plugin system
This commit is contained in:
parent
1b427cf225
commit
98f8ec071c
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -5,6 +5,7 @@
|
||||||
"api",
|
"api",
|
||||||
"cli",
|
"cli",
|
||||||
"federation",
|
"federation",
|
||||||
"config"
|
"config",
|
||||||
|
"plugin"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
packages/plugin-kit/example.ts
Normal file
20
packages/plugin-kit/example.ts
Normal 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;
|
||||||
|
});
|
||||||
4
packages/plugin-kit/hooks.ts
Normal file
4
packages/plugin-kit/hooks.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export type ServerHooks = {
|
||||||
|
request: (request: Request) => Request;
|
||||||
|
response: (response: Response) => Response;
|
||||||
|
};
|
||||||
5
packages/plugin-kit/index.ts
Normal file
5
packages/plugin-kit/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Plugin } from "./plugin";
|
||||||
|
import type { Manifest } from "./schema";
|
||||||
|
|
||||||
|
export type { Manifest };
|
||||||
|
export { Plugin };
|
||||||
41
packages/plugin-kit/package.json
Normal file
41
packages/plugin-kit/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
packages/plugin-kit/plugin.ts
Normal file
87
packages/plugin-kit/plugin.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
packages/plugin-kit/schema.ts
Normal file
99
packages/plugin-kit/schema.ts
Normal 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>>>();
|
||||||
49
packages/plugin-kit/tests/manifest.test.ts
Normal file
49
packages/plugin-kit/tests/manifest.test.ts
Normal 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"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue