Repository navigation
Expansions
The expansion API lets your plugin share its data through WorldAPI with a few typed classes. You list resources clients may read and the events you publish; WorldAPI serves them at /api/v1/extensions/<id>/…, streams the events on the WebSocket, keeps the recorded ones in its history, and the MCP server reads them through its list_extensions and read_extension tools. Your plugin stays the source of truth: WorldAPI reads a resource when a client asks and stores none of it.
The library is plain Java without dependencies, so it works from Java, Kotlin, or any JVM language, and the same expansion will run on the Fabric and NeoForge builds of WorldAPI once they arrive. It's the only way since WorldAPI 4.0.0, which removed the older map-based bridge in Bukkit's ServicesManager; Extensions shows what clients read.
com.mairwunnx:worldapi-expansion is on Maven Central. Add it as compileOnly: WorldAPI's jar carries the same classes, so yours never should.
// build.gradle.kts
repositories { mavenCentral() }
dependencies { compileOnly("com.mairwunnx:worldapi-expansion:1.0.0") }<!-- pom.xml -->
<dependency>
<groupId>com.mairwunnx</groupId>
<artifactId>worldapi-expansion</artifactId>
<version>1.0.0</version>
<scope>provided</scope>
</dependency>The API has its own version, apart from WorldAPI's. Within a major version it only grows, so an expansion built against 1.0 runs on every WorldAPI with API 1.x. WorldAPI 3.4.0 brings 1.0.
Your plugin needs WorldAPI's copy of the classes. In plugin.yml, list it in softdepend (or depend, if your plugin can't run without it):
softdepend: [WorldAPI]A Paper plugin joins its class path in paper-plugin.yml:
dependencies:
server:
WorldAPI:
load: BEFORE
required: false
join-classpath: truepackage com.example.bosses;
import com.mairwunnx.worldapi.expansion.EventType;
import com.mairwunnx.worldapi.expansion.ExpansionApi;
import com.mairwunnx.worldapi.expansion.Resource;
import com.mairwunnx.worldapi.expansion.WorldApiExpansion;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
public final class BossesExpansion implements WorldApiExpansion {
private final BossesPlugin plugin;
public BossesExpansion(BossesPlugin plugin) {
this.plugin = plugin;
}
@Override
public String id() {
return "bosses"; // /extensions/bosses/... and the topics ext.bosses.*
}
@Override
public String requiresApi() {
return ExpansionApi.VERSION;
}
@Override
public String description() {
return "Bosses and their hunters";
}
@Override
public List<Resource> resources() {
return List.of(
// Off the server thread: the handler reads the plugin's own thread-safe data or its database.
Resource.of("hunters", "The best hunters; ?limit=1..50", request ->
plugin.top(request.query("limit", 10, 1, 50))).publicly(),
// A CompletionStage lets the database answer on its own thread; WorldAPI waits up to 10 seconds.
Resource.of("players/{player}", "A player's card: level and kills", request ->
plugin.loadCard(request.path("player")).thenApply(card -> {
if (card == null) throw new NoSuchElementException("No hunter named " + request.path("player"));
return Map.of("name", card.name(), "level", card.level(), "kills", card.kills());
})).publicly(),
// On the server thread, since it reads entities. Coordinates only for those who may see them.
Resource.of("bosses", "The bosses alive, with health", request ->
plugin.aliveBosses().stream().map(boss -> request.seesCoordinates()
? Map.of("type", boss.type(), "health", boss.health(), "location", boss.location())
: Map.of("type", boss.type(), "health", boss.health())).toList()).onServerThread());
}
@Override
public List<EventType> events() {
return List.of(
EventType.of("boss.killed", "Players killed a boss").publicly().recorded(),
EventType.of("boss.spawned", "A boss appeared"));
}
}Either register in code, only when WorldAPI is there:
@Override
public void onEnable() {
if (getServer().getPluginManager().getPlugin("WorldAPI") != null) WorldApiExpansions.register(new BossesExpansion(this));
}Or let WorldAPI find it: put your class's full name in src/main/resources/META-INF/services/com.mairwunnx.worldapi.expansion.WorldApiExpansion. WorldAPI then makes it with its public constructor without arguments when your plugin is enabled, and nothing in your code mentions WorldAPI. An expansion found this way can reach your plugin through JavaPlugin.getPlugin(BossesPlugin.class).
Either way WorldAPI drops your expansion when your plugin stops; WorldApiExpansions.unregister("bosses") does it earlier. The log says what it took:
[WorldAPI] Bosses shares bosses: 3 resources, 2 topics
WorldApiExpansions.publish("bosses", Event.of("boss.killed", Map.of("type", "zombie", "level", 40))
.player(player.getUniqueId(), player.getName())
.world(player.getWorld().getName()));publish is safe from any thread; the event reaches clients from the server thread. It returns false, and drops the event, while WorldAPI isn't running, so you can call it without checking. An event your expansion doesn't list throws IllegalArgumentException.
- A path is segments of
a-z 0-9 _ -, or{name}for a parameter, whichrequest.path("name")gives back. -
request.query("limit")reads a query parameter, null when it's missing;request.query("limit", 10, 1, 50)reads a whole number with a default and bounds, and answers 400 for anything else. -
request.anonymous(),request.can("players:private"), andrequest.seesCoordinates()say who asks, so one resource can show more to some clients. Tokens and permissions lists the permissions. - A handler returns plain Java values: maps, lists, arrays, strings, numbers, booleans, and null. UUIDs, enums, and times turn into strings, anything else into its
toString(). ACompletionStageof those lets you finish on your own thread. - Throw
IllegalArgumentExceptionfor 400 andNoSuchElementExceptionfor 404, each with a short message for the client. Any other exception answers 502. - Handlers run on a worker thread unless the resource calls
onServerThread(). There, keep them quick and never wait on the network or a database.
Each type becomes the topic ext.<id>.<name>, like ext.bosses.boss.killed. recorded() keeps the events in GET /events; publicly() lets visitors subscribe. Visitors never get the coordinate keys in the data (location, position, and the like) unless the server's owner opened coordinates to them, and an event about a vanished player shows only to clients with players:moderate, as WorldAPI's own do.
A resource or a topic needs a token unless it's publicly(). Even then, the server's owner can hide your namespace, one resource (bosses/bosses), or one topic in extensions.private in WorldAPI's configuration. Never make public what a visitor shouldn't see: coordinates, inventories, addresses, or chat.
- The id is 1 to 32 of
a-z 0-9 _ -, and one plugin owns it: another plugin can't take it. -
requiresApi()must be a version this WorldAPI supports: the same major, and a minor no higher than its own. - An event type appears once.
WorldAPI refuses a broken expansion with a line in its log, and your plugin keeps running.
The API doesn't depend on Bukkit, Fabric, or NeoForge, so one expansion class serves every platform. WorldAPI's mods find expansions the same way, through META-INF/services in any mod's jar or WorldApiExpansions.register. Depend on com.mairwunnx:worldapi-expansion with compileOnly (Loom's modCompileOnly isn't needed: it's plain Java), and add WorldAPI to your mod's optional dependencies so it loads first. In singleplayer, WorldAPI starts with each world you open and stops when you leave. A registered expansion stays registered in between and reaches every next world; publish returns false while no world is open, and available() says whether one is.
Next: Java scripts.