Files
skills/docs/gaming/games/game-server/src/engine/plugin-manager/plugin-manager.md
2026-05-29 08:20:56 +01:00

5.5 KiB

PluginManager

(no submodules)

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

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

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.