mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Host not connected, host shutting down and instance not running are now UnavailableError (503) instead of ConflictError. The request error migration notes move to a new 2.1 guide, since this ships in 2.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
334 lines
13 KiB
Markdown
334 lines
13 KiB
Markdown
# Migrating Plugins to 2.0
|
|
|
|
This guide covers the changes needed to bring a plugin written for 2.0.0-alpha.27 up to 2.0.0.
|
|
If your plugin targets an older alpha, go through the Breaking Changes sections in the [changelog](/CHANGELOG.md) for the versions in between first.
|
|
Plugins still on alpha 13 should start with the [alpha 14 guide](plugin-migration-to-alpha-14.md).
|
|
|
|
|
|
## Versioning from 2.0
|
|
|
|
From 2.0.0 onwards a major version (3.0.0) contains breaking changes for users running a cluster, while a minor version (2.1.0) may contain breaking changes for the plugin API.
|
|
This means a plugin written for 2.0 is not guaranteed to work with 2.1 without changes.
|
|
Check the Breaking Changes section of the changelog when a new minor version is released and publish an update to your plugin if needed.
|
|
See [Breaking Changes](/docs/decisions.md#breaking-changes) in the decision log.
|
|
|
|
|
|
## Convert to ES modules
|
|
|
|
Clusterio's packages are now ES modules and plugins should be too.
|
|
Add `"type": "module"` to the plugin's `package.json` and replace `require()` and `module.exports` with `import` and `export`.
|
|
|
|
Keep `"clusterio-plugin"` in the package's `"keywords"` for automatic discovery, and keep `"main"` pointing to the plugin info module (or use the default `index.js`).
|
|
An `"exports"` map is not required for ES modules.
|
|
If you add one, expose `"./package.json": "./package.json"`; otherwise Clusterio cannot discover or load the plugin by its package name.
|
|
Clusterio currently reads the implementation path from `"main"` rather than conditional exports.
|
|
See [Plugin Structure](../writing-plugins.md#plugin-structure) for a compatible package example.
|
|
|
|
Relative imports must include the full filename with the `.js` extension.
|
|
This also applies in TypeScript, where you import `./messages.js` even though the source file is `messages.ts`.
|
|
Importing a directory no longer resolves to its `index.js`.
|
|
|
|
**Alpha 27**
|
|
```js
|
|
"use strict";
|
|
const lib = require("@clusterio/lib");
|
|
const messages = require("./messages");
|
|
|
|
const plugin = {
|
|
name: "foo_frobber",
|
|
controllerEntrypoint: "controller",
|
|
messages: [messages.Frobnicate],
|
|
};
|
|
|
|
module.exports = {
|
|
plugin,
|
|
};
|
|
```
|
|
|
|
**2.0**
|
|
```js
|
|
import * as lib from "@clusterio/lib";
|
|
import * as messages from "./messages.js";
|
|
|
|
export const plugin = {
|
|
name: "foo_frobber",
|
|
controllerEntrypoint: "controller.js",
|
|
messages: [messages.Frobnicate],
|
|
};
|
|
```
|
|
|
|
The entrypoint paths in the plugin declaration must include the `.js` extension as well, for TypeScript plugins this means `"./dist/node/controller.js"` instead of `"./dist/node/controller"`.
|
|
|
|
`__dirname` and `__filename` do not exist in ES modules, use `import.meta.dirname` and `import.meta.filename` instead.
|
|
JSON files are imported with `import packageJson from "./package.json" with { type: "json" };`.
|
|
|
|
A `webpack.config.js` written with `require()` stops working once the package is an ES module.
|
|
Rename it to `webpack.config.cjs`, webpack-cli finds it under that name without further changes.
|
|
If your `prepare` script passes `--config webpack.config.js` update it to the new name.
|
|
|
|
TypeScript plugins using `"module": "node16"` in their tsconfig, as the plugin templates do, emit ES modules once `"type": "module"` is set and need no further config changes.
|
|
|
|
Node.js can still import a CommonJS plugin, but it only finds the `plugin` export if it can detect it from the source, which works for `module.exports = { plugin };` and fails for other forms such as an object literal written in place.
|
|
Converting the plugin avoids the problem.
|
|
|
|
|
|
## Replace plugin classes with entrypoint functions
|
|
|
|
Entrypoints now export a default async function instead of a class deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin`.
|
|
The function is called once when the plugin loads and attaches handlers to the hooks it needs.
|
|
Class exports still load, but they are deprecated and a warning is logged for each one.
|
|
See [Defining the plugin entrypoint](/docs/writing-plugins.md#defining-the-plugin-entrypoint) for the details.
|
|
|
|
**Alpha 27**
|
|
```js
|
|
const { BaseControllerPlugin } = require("@clusterio/controller");
|
|
|
|
class ControllerPlugin extends BaseControllerPlugin {
|
|
async init() {
|
|
this.controller.handle(Frobnicate, this.handleFrobnicate.bind(this));
|
|
}
|
|
|
|
async onSaveData() {
|
|
await this.saveFrobnicationData();
|
|
}
|
|
|
|
async onInstanceStatusChanged(instance, prev) {
|
|
this.logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
|
|
}
|
|
|
|
async handleFrobnicate(request) { /* ... */ }
|
|
}
|
|
|
|
module.exports = {
|
|
ControllerPlugin,
|
|
};
|
|
```
|
|
|
|
**2.0 (JS)**
|
|
```js
|
|
export default async function(context) {
|
|
const { controller, logger, plugin } = context;
|
|
|
|
controller.handle(Frobnicate, async (request) => { /* ... */ });
|
|
|
|
controller.hooks.save.attach(plugin.name, async () => {
|
|
await saveFrobnicationData();
|
|
});
|
|
|
|
controller.hooks.instanceStatusChanged.attach(plugin.name, async (instance, prev) => {
|
|
logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
|
|
});
|
|
}
|
|
```
|
|
|
|
**2.0 (TS)**
|
|
```ts
|
|
import type { ControllerPluginContext } from "@clusterio/controller";
|
|
|
|
export default async function(context: ControllerPluginContext) {
|
|
// Same as above
|
|
}
|
|
```
|
|
|
|
The code from `init()` goes into the body of the function, and state that lived on `this` becomes local variables.
|
|
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 and attaches its hooks.
|
|
`fromContext` can then be the default export of the entrypoint.
|
|
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();
|
|
context.controller.hooks.save.attach(context.plugin.name, () => frobber.save());
|
|
Frobber.instances.set(context.controller, frobber);
|
|
return frobber;
|
|
}
|
|
|
|
static get(controller: Controller) {
|
|
return Frobber.instances.get(controller);
|
|
}
|
|
|
|
async load() { /* ... */ }
|
|
async save() { /* ... */ }
|
|
}
|
|
|
|
export default Frobber.fromContext;
|
|
```
|
|
|
|
The loader calls the default export without `this`, so refer to the class by name inside `fromContext` rather than using `this`.
|
|
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`.
|
|
The exceptions are:
|
|
|
|
- `onSaveData` on the controller is `controller.hooks.save`.
|
|
- `addCommands` on ctl plugins is `context.hooks.addCommands`.
|
|
- The `pages`, `loginForms` and `inputComponents` properties of web plugins are the `pages`, `loginForms` and `inputComponents` hooks on `control.hooks`, with handlers that return the value.
|
|
- The `componentExtra` property of web plugins is the `control.hooks.extensionComponents` hook.
|
|
|
|
Controller hooks are on `controller.hooks`, host hooks on `host.hooks`, instance hooks on `instance.hooks`, and web hooks on `control.hooks`.
|
|
The ctl hooks are passed directly as `context.hooks`.
|
|
|
|
**Alpha 27**
|
|
```jsx
|
|
class WebPlugin extends BaseWebPlugin {
|
|
async init() {
|
|
this.pages = [
|
|
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
**2.0**
|
|
```jsx
|
|
export default async function(context) {
|
|
const { control, plugin } = context;
|
|
|
|
control.hooks.pages.attach(plugin.name, () => [
|
|
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
|
|
]);
|
|
}
|
|
```
|
|
|
|
|
|
### Replacing other class members
|
|
|
|
The class properties are on the context object passed to the entrypoint:
|
|
|
|
- `this.info` is `context.plugin`.
|
|
- `this.logger` is `context.logger`.
|
|
- `this.controller`, `this.host`, `this.instance` and `this.control` are `context.controller`, `context.host`, `context.instance` and `context.control`.
|
|
- `this.metrics` on controller plugins is `context.metrics`.
|
|
- `this.package` on web plugins is `context.package`.
|
|
|
|
The helper methods of the base classes have the following replacements:
|
|
|
|
- `this.sendRcon(message, expectEmpty)` is `instance.sendRcon(message, expectEmpty, plugin.name)`.
|
|
The plugin name is used to label the RCON metrics.
|
|
- `this.broadcastEventToHosts(event)` is `controller.sendTo("allHosts", event)`.
|
|
- `this.sendOrderedRcon(message)` has no replacement.
|
|
If your plugin needs commands to execute in order, queue them in the plugin and send the next one after the previous one resolves.
|
|
|
|
|
|
### Changes to hook behaviour
|
|
|
|
Handlers attached by different plugins to the same hook now run concurrently, where the `on*` methods were called one plugin at a time.
|
|
Don't rely on another plugin's handler having finished before yours runs.
|
|
|
|
A handler that throws or takes more than 15 seconds is logged and does not prevent the other handlers from running.
|
|
Each hook accepts one handler per name, attach it under `plugin.name`.
|
|
|
|
|
|
## Accessing other plugins
|
|
|
|
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.
|
|
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 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 its class and use the static lookup from [Keeping a class for state](#keeping-a-class-for-state):
|
|
|
|
```ts
|
|
import { Frobber } from "other_plugin/dist/node/controller.js";
|
|
|
|
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.
|
|
|
|
|
|
## Declare permissions on the plugin
|
|
|
|
Permissions can now be declared under `permissions` in the plugin declaration instead of with `lib.definePermission()`.
|
|
`lib.definePermission()` still works, but don't use both for the same permission name.
|
|
|
|
For TypeScript plugins `user.checkPermission()`, `account.hasPermission()` and the `permission` of web pages now only accept known permission names.
|
|
Add your permission names to the `Permissions` interface or these calls will fail to compile, even if you keep using `lib.definePermission()`:
|
|
|
|
```ts
|
|
declare module "@clusterio/lib" {
|
|
export interface Permissions {
|
|
"foo_frobber.frobnicate": never;
|
|
}
|
|
}
|
|
```
|
|
|
|
The plugin template's `tsconfig.browser.json` now includes `index.ts` so the web build also sees the declaration, add it to the `include` list if your plugin has its own tsconfig for the browser.
|
|
See [Plugin Permissions](/docs/writing-plugins.md#plugin-permissions).
|
|
|
|
|
|
## Renamed ctl classes and config
|
|
|
|
The remaining uses of "control" for clusterioctl were renamed to "ctl":
|
|
|
|
- `controlConfigFields` in the plugin declaration is `ctlConfigFields`.
|
|
Fields under the old name are silently ignored.
|
|
- The `ControlConfigFields` interface is `CtlConfigFields`.
|
|
- `lib.ControlConfig` is `lib.CtlConfig`.
|
|
- The `Control` class exported by `@clusterio/ctl` is `Ctl`.
|
|
|
|
The term "control" is still used in message `src` and `dst` and in config field locations, where it covers both ctl and the web interface.
|
|
|
|
|
|
## Static assets in the web interface
|
|
|
|
`staticRoot` now includes the `static/` prefix.
|
|
Plugins that fetch assets with `${staticRoot}static/...` should drop the `static/` part.
|
|
|
|
|
|
## Package dependencies
|
|
|
|
npm installs peer dependencies by default, so listing `@clusterio/web_ui` under `peerDependencies` installs it everywhere the plugin is installed even though it's only needed to build the web bundle.
|
|
Move `@clusterio/web_ui` to `devDependencies` and mark `@clusterio/controller`, `@clusterio/host` and `@clusterio/ctl` as optional in `peerDependenciesMeta`:
|
|
|
|
```json
|
|
"peerDependencies": {
|
|
"@clusterio/controller": "^2.0.0",
|
|
"@clusterio/host": "^2.0.0",
|
|
"@clusterio/lib": "^2.0.0"
|
|
},
|
|
"peerDependenciesMeta": {
|
|
"@clusterio/controller": {
|
|
"optional": true
|
|
},
|
|
"@clusterio/host": {
|
|
"optional": true
|
|
}
|
|
}
|
|
```
|