Show the fromContext class pattern in the migration guide

Plugins that still want a class for state use a private constructor,
an async static fromContext and a static WeakMap lookup, which also
replaces controller.plugins.get() for cross-plugin access. Check
loadedPlugins with Set.has on the info from pluginInfos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-08 10:13:38 +00:00
1 parent 5814b89821
commit d3e9803069
1 file changed
+54 -13
+54 -13
View File
@@ -128,6 +128,49 @@ The code from `init()` goes into the body of the function, and state that lived
The context types are `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from the respective packages.
### Keeping a class for state
If the plugin has enough state that a class is still the better fit, give the class a private constructor and an async `static fromContext` that creates it from the context.
The entrypoint calls `fromContext` and attaches the hooks to methods on the returned object.
Instances are kept in a static `WeakMap` keyed by the controller, host or instance they belong to, which lets other code look them up without holding a reference to them.
```ts
import type { Controller, ControllerPluginContext } from "@clusterio/controller";
import type * as lib from "@clusterio/lib";
export class Frobber {
private static instances = new WeakMap<Controller, Frobber>();
private frobnications = new Map<string, number>();
private constructor(
private controller: Controller,
private logger: lib.Logger,
) { }
static async fromContext(context: ControllerPluginContext) {
const frobber = new Frobber(context.controller, context.logger);
await frobber.load();
Frobber.instances.set(context.controller, frobber);
return frobber;
}
static get(controller: Controller) {
return Frobber.instances.get(controller);
}
async load() { /* ... */ }
async save() { /* ... */ }
}
export default async function(context: ControllerPluginContext) {
const frobber = await Frobber.fromContext(context);
context.controller.hooks.save.attach(context.plugin.name, () => frobber.save());
}
```
Don't extend the deprecated base classes for this, they attach `on*` methods to hooks themselves and log a warning.
### Method to hook names
Each `on*` method has a hook of the same name without the `on` prefix and with the first letter lowercased, so `onInstanceStatusChanged` becomes `controller.hooks.instanceStatusChanged`.
@@ -196,32 +239,30 @@ Each hook accepts one handler per name, attach it under `plugin.name`.
The `plugins` map on `Controller`, `Host` and `Instance` has been removed.
It's replaced with `loadedPlugins`, a set of the plugin info objects for the plugins that loaded.
To check if a plugin is loaded on the controller:
The set holds info objects rather than names, so look the info up in `pluginInfos` by name first to check if a plugin is loaded on the controller or host:
```js
const loaded = [...controller.loadedPlugins].some(info => info.name === "other_plugin");
const info = controller.pluginInfos.find(info => info.name === "other_plugin");
const loaded = info !== undefined && controller.loadedPlugins.has(info);
```
In the web interface `control.loadedPlugins` is a map from plugin name to plugin info.
`control.plugins` is deprecated and only holds plugins still using the class export.
With no plugin class instances, `controller.plugins.get("other_plugin")` has no direct replacement.
If one plugin needs to call into another, have the other plugin export functions from its module and import them.
State that's tied to a particular controller, host or instance can be kept in a `WeakMap` keyed by that object:
If one plugin needs to call into another, have the other plugin export its class and use the static lookup from [Keeping a class for state](#keeping-a-class-for-state):
```js
// In other_plugin/controller.js
const frobbers = new WeakMap();
```ts
import { Frobber } from "other_plugin/dist/node/controller.js";
export function getFrobber(controller) {
return frobbers.get(controller);
}
export default async function(context) {
frobbers.set(context.controller, new Frobber(context));
const frobber = Frobber.get(controller);
if (!frobber) {
throw new Error("other_plugin is not loaded");
}
```
This only works if both plugins resolve `other_plugin` to the same file on disk, otherwise the lookup goes to a different copy of the class and returns undefined.
For state shared between components of a web plugin, assign it to a module level variable in the entrypoint.
The web plugin template created by `@clusterio/create` shows this for a subscriber.