first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
# PluginManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PluginManager` is the typed plugin registry for `@suaveplan/game-server`. It owns the install/uninstall/enable/disable lifecycle, drives plugin dependency ordering via topological sort, and wraps every plugin lifecycle hook in a single discriminable error code (`PLUGIN_LIFECYCLE_FAILED`) so consumers can centralise their error handling without scraping cause chains.
The manager carries no global state — plugins are stored under their declared `name`, and the install order is captured separately so `disposeAll()` can tear plugins down in reverse. Every state-changing call (`install`, `uninstall`, `enable`, `disable`) emits both a structured log line and a typed event onto the injected `EventBusLike`, and bumps the `game_server.plugin.lifecycle_total` OTel counter with a low-cardinality `op`/`outcome` attribute pair.
## Features
- Typed `EnginePlugin` contract with optional `onInstall` / `onUninstall` / `onEnable` / `onDisable` hooks.
- Zod-validated plugin shape on every registration path.
- `DUPLICATE_PLUGIN` on second install with the same name; `PLUGIN_NOT_FOUND` on every unknown-name lookup; `PLUGIN_LIFECYCLE_FAILED` wrapping hook errors, missing deps, and cycles.
- `installAll(plugins)` accepts an unordered batch and resolves dependencies via topological sort.
- `disposeAll()` tears plugins down in reverse install order; `isEnabled` is checked before `onDisable` fires (so callers can call uninstall on a still-enabled plugin without an explicit disable first).
- Structured logging + typed event emission on every state change.
- OTel `game_server.plugin.lifecycle_total` counter incremented with `op`/`outcome` attributes.
## Usage: Basic
```typescript
import { PluginManager } from "@suaveplan/game-server/engine/plugin-manager";
const plugins = new PluginManager({ logger, events });
await plugins.install({
name: "metrics",
version: "1.0.0",
async onInstall(ctx) {
ctx.logger.info("metrics plugin online");
},
});
await plugins.enable("metrics");
```
## Usage: Advanced
```typescript
import { PluginManager } from "@suaveplan/game-server/engine/plugin-manager";
const plugins = new PluginManager({ logger, events });
await plugins.installAll([
{ name: "metrics", version: "1.0.0" },
{
name: "leaderboard",
version: "0.3.0",
dependencies: ["metrics"],
async onInstall(ctx) {
ctx.events.emit("plugin:installed", {
name: "leaderboard",
version: "0.3.0",
});
},
},
]);
// `metrics` is installed first because `leaderboard.dependencies = ["metrics"]`.
await plugins.enable("leaderboard");
await plugins.enable("metrics");
// Errors carry the offending plugin + hook name:
try {
await plugins.install({
name: "broken",
version: "0.1.0",
async onInstall() {
throw new Error("boom");
},
});
} catch (err) {
if (err.code === "GAME_SERVER.PLUGIN_LIFECYCLE_FAILED") {
console.log(err.context); // { name: "broken", hook: "onInstall" }
}
}
```
## API Reference
### `class PluginManager`
#### `new PluginManager(deps)`
`deps`: `{ logger: LoggerLike; events: EventBusLike<PluginManagerEventMap> }`.
#### `install(plugin): Promise<void>` / `installAll(plugins): Promise<void>`
`install` rejects with `DUPLICATE_PLUGIN` on a re-install. `installAll`
topologically orders the batch; a missing dependency or a cycle rejects
with `PLUGIN_LIFECYCLE_FAILED` whose `context` carries either `{ missing, requestedBy }`
or `{ cycle }`.
#### `uninstall(name): Promise<void>`
Disables the plugin first if it is currently enabled, then runs
`onUninstall`. Rejects with `PLUGIN_NOT_FOUND` on unknown name.
#### `enable(name): Promise<void>` / `disable(name): Promise<void>`
Idempotent — calling `enable` on an already-enabled plugin is a no-op
that does not run the hook again.
#### `get(name): EnginePlugin` / `has(name): boolean` / `isEnabled(name): boolean`
`get` and `isEnabled` throw `PLUGIN_NOT_FOUND` on unknown name; `has`
returns a boolean.
#### `list(): readonly string[]` / `getStats(): PluginManagerStats`
`list()` returns the install order; `getStats()` adds `enabled`/`disabled`
counts.
#### `disposeAll(): Promise<void>`
Uninstall every plugin in reverse install order.
### Types
`EnginePlugin`, `EnginePluginContext`, `PluginManagerStats`,
`PluginManagerEventMap`, `PluginManagerDeps`.
## Implementation Notes
Topological sort uses the standard three-set Tarjan algorithm
(`visited` / `visiting` plus a `path` accumulator) so cycles surface with
the offending edge sequence rather than a generic message. The error
carries the cycle string in `context.cycle` so log scrapers can group by
the participating plugins without parsing free text.
The decision to fold "schema validation" failures into the
`PLUGIN_LIFECYCLE_FAILED` code rather than a dedicated `INVALID_PLUGIN`
code keeps the error surface narrow — every plugin-related failure has
the same discriminable code, with the specific failure mode visible in
`context.hook` or `context.missing` or `context.cycle`. Consumers that
want to branch on the precise failure mode read those fields; the
`code` field is enough for a catch-all log emit.
`disposeAll` walks the install order in reverse so plugins with declared
dependencies tear down after their dependents. This mirrors the
upstream contract: a logging plugin installed first must be the last to
go, because every other plugin's `onUninstall` may still need to log.