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
EnginePlugincontract with optionalonInstall/onUninstall/onEnable/onDisablehooks. - Zod-validated plugin shape on every registration path.
DUPLICATE_PLUGINon second install with the same name;PLUGIN_NOT_FOUNDon every unknown-name lookup;PLUGIN_LIFECYCLE_FAILEDwrapping 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;isEnabledis checked beforeonDisablefires (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_totalcounter incremented withop/outcomeattributes.
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.