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",
|
||||
"cli",
|
||||
"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