Skip to content

Messaging

Alberto Migliorato edited this page Jun 28, 2026 · 1 revision

Cross-Server Messaging

PowerLib ships a small, typed publish/subscribe layer for talking between a proxy (BungeeCord / Velocity) and its backend Spigot servers. You declare a Channel<T>, hand it a payload type, and publish / subscribe to that type. PowerLib serializes the payload, multiplexes it over a single Minecraft plugin-messaging channel (powerlib:main), and hands the other end the decoded object.

No DataOutputStream boilerplate per message, no juggling raw byte[] at the call site, and no shared password travelling on the wire to "secure" an ad-hoc protocol: both ends import the same Channel declaration and exchange real DTOs.

This page covers the common API (Channel, Codec, Messenger, Messengers), how framing multiplexes logical channels, and the per-platform transport that actually moves the bytes.

All messaging types live in it.mycraft.powerlib.common.messaging, shipped in powerlib-common. The platform transports live in the powerlib-bukkit, powerlib-bungee, and powerlib-velocity modules and are picked up automatically — see Transports.


Table of contents


The model

Four common types, each doing exactly one thing:

Type Role
Channel<T> A named, typed channel. The same declaration is shared by both ends so they agree on the name and the payload type.
Codec<T> Serializes a payload T to and from byte[]. Codec.string() is built in; write your own for DTOs.
Messenger Typed pub/sub. publish(channel, payload) encodes and sends; subscribe(channel, handler) decodes inbound frames and dispatches them.
Messengers Resolves the platform MessageTransport via ServiceLoader and exposes a ready singleton Messenger.

Underneath sits a MessageTransport (the raw, channel-tagged byte[] wire) which you normally never touch — each platform module supplies its own.

publish(channel, payload)
      │  channel.encode(payload)        ── Codec<T>  T → byte[]
      ▼
 MessageTransport.send(name, bytes)
      │  Framing.frame(name, bytes)     ── multiplex onto one MC channel
      ▼
 powerlib:main  ──────────────────────►  (other servers)
      │  Framing.parse(...)             ── recover (name, bytes)
      ▼
 Messenger.receive(name, bytes)
      │  channel.decode(bytes)          ── Codec<T>  byte[] → T
      ▼
 subscriber.accept(payload)

Declaring a channel

A Channel<T> is just a name plus a Codec<T>. Declare it once, in code both the proxy and the backend can see (a shared module, or copied identically), and reference that single declaration from both sides:

import it.mycraft.powerlib.common.messaging.Channel;
import it.mycraft.powerlib.common.messaging.Codec;

public final class Channels {
    private Channels() {}

    // Plain-text channel using the built-in string codec.
    public static final Channel<String> PING =
            Channel.of("ping", Codec.string());
}

The channel name ("ping" here) is the logical address inside the powerlib:main link — it is not a Minecraft plugin-messaging channel of its own. Many Channels coexist over the one MC channel; the name is what keeps them apart (see Framing). Names are matched verbatim, so both ends must use the same string.

The built-in string codec

Codec.string() encodes a String as UTF-8 bytes and back. Good for pings, simple commands, or a payload you have already serialized yourself:

Codec<String> codec = Codec.string();
byte[] wire = codec.encode("hello");     // UTF-8 bytes
String back = codec.decode(wire);        // "hello"

Writing a JSON codec for a DTO

For anything structured, give the channel a codec that serializes your DTO. Codec<T> is a two-method interface:

public interface Codec<T> {
    byte[] encode(T value);
    T decode(byte[] data);
}

powerlib-common already depends on the javax.json API, so you can write a JSON codec with no extra dependency. Given a DTO:

public record Announcement(String server, String message, int priority) {}

a codec for it:

import it.mycraft.powerlib.common.messaging.Codec;

import javax.json.Json;
import javax.json.JsonObject;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;

public final class AnnouncementCodec implements Codec<Announcement> {

    @Override
    public byte[] encode(Announcement a) {
        String json = Json.createObjectBuilder()
                .add("server", a.server())
                .add("message", a.message())
                .add("priority", a.priority())
                .build()
                .toString();
        return json.getBytes(StandardCharsets.UTF_8);
    }

    @Override
    public Announcement decode(byte[] data) {
        try (var reader = Json.createReader(new ByteArrayInputStream(data))) {
            JsonObject o = reader.readObject();
            return new Announcement(
                    o.getString("server"),
                    o.getString("message"),
                    o.getInt("priority"));
        }
    }
}

and the channel that uses it:

public static final Channel<Announcement> ANNOUNCEMENTS =
        Channel.of("announcements", new AnnouncementCodec());

Any serializer works — Gson, Jackson, a hand-rolled DataOutputStream — as long as both ends use the same codec for the channel. JSON is the lazy, debuggable default; reach for a binary codec only if you have measured that you need it.


Publishing and subscribing

Get the shared Messenger from Messengers.get(). It is built once per server from whatever transport that platform registered.

Subscribe (typically on startup) — the handler receives the already-decoded payload:

import it.mycraft.powerlib.common.messaging.Messenger;
import it.mycraft.powerlib.common.messaging.Messengers;

Messenger messenger = Messengers.get();
if (messenger != null) {
    messenger.subscribe(Channels.ANNOUNCEMENTS, announcement -> {
        Bukkit.broadcastMessage(
                "[" + announcement.server() + "] " + announcement.message());
    });
}

Publish — pass a real object, not bytes:

messenger.publish(Channels.ANNOUNCEMENTS,
        new Announcement("lobby", "Restart in 5 minutes", 1));

Notes:

  • Messengers.get() returns null when no transport is registered (for example a unit-test classpath, or a platform with messaging unavailable). Guard it — do not assume non-null.
  • You may register more than one handler for the same channel; every subscriber is invoked for each inbound payload.
  • A Channel with no subscriber on the receiving end is simply ignored there — its inbound frames are dropped, decoded by nobody. Publishing to a channel nobody listens on is harmless.
  • Subscription and dispatch are by channel name. Keep your Channel declarations in one shared place so a typo can't silently split a channel in two.

How it travels: framing and transports

Framing

Minecraft gives a plugin one named plugin-messaging channel at a time, but PowerLib wants many logical channels. Framing bridges the gap: it packs the logical channel name in front of the payload so a single MC channel can carry all of them.

public record Frame(String channel, byte[] data) {}

byte[] frame = Framing.frame("announcements", payloadBytes); // name + length + bytes
Framing.Frame parsed = Framing.parse(frame);                 // → ("announcements", payloadBytes)

On the wire a frame is writeUTF(name) + writeInt(length) + the raw payload. Every transport send wraps its bytes with Framing.frame(...), and every transport onPluginMessage/listener unwraps with Framing.parse(...) before handing (name, data) to the Messenger. You never call Framing yourself — it is the glue between MessageTransport and the single MC channel powerlib:main.

Transports per platform

A MessageTransport is the raw byte wire under the Messenger. Each platform module provides one and registers it through the Java ServiceLoader mechanism (a META-INF/services/it.mycraft.powerlib.common.messaging.MessageTransport file naming the class). Messengers loads the first one it finds:

MessageTransport transport =
        ServiceLoader.load(MessageTransport.class, ...).findFirst().orElse(null);

That is why Messengers.get() "just works" on each platform and returns null where no module contributed a transport. All three transports ride the same MC channel, powerlib:main, so a proxy and its backends interoperate out of the box.

Platform Transport MC channel Inbound Outbound send semantics
Spigot / Paper BukkitMessageTransport powerlib:main PluginMessageListener#onPluginMessageReceived Sent through the first online player as carrier. No players online ⇒ the frame is dropped.
BungeeCord BungeeMessageTransport powerlib:main @EventHandler PluginMessageEvent (cancelled) Broadcast to every backend via ServerInfo#sendData(...).
Velocity VelocityMessageTransport powerlib:main @Subscribe PluginMessageEvent (marked handled) Broadcast to every registered server via RegisteredServer#sendPluginMessage(...).

The asymmetry is inherent to Minecraft plugin messaging, not a PowerLib choice:

  • Backends need a player. A Spigot server can only emit a plugin message through a connected player's connection — that is the only pipe to the proxy. With nobody online there is no pipe, so BukkitMessageTransport.send finds no carrier and the frame is silently dropped.
  • Proxies broadcast. Bungee and Velocity hold the connections to every backend, so their send fans the frame out to all servers. There is no per-server addressing in this API — if you only want one backend to act, include a target in the payload and let receivers filter (e.g. the server field in Announcement above).

End-to-end example

A backend announces something; the proxy relays it to the other backends.

Shared declaration (visible to both sides — same code, same names):

public final class Channels {
    private Channels() {}

    public static final Channel<Announcement> ANNOUNCEMENTS =
            Channel.of("announcements", new AnnouncementCodec());
}

Backend (Spigot) — publish on a command:

public void announce(String message) {
    Messenger messenger = Messengers.get();
    if (messenger == null) return;                 // messaging unavailable
    messenger.publish(Channels.ANNOUNCEMENTS,
            new Announcement(serverName, message, 1));
}

Proxy (Velocity / Bungee) — subscribe and re-broadcast:

Messenger messenger = Messengers.get();
if (messenger != null) {
    messenger.subscribe(Channels.ANNOUNCEMENTS, a ->
            // proxy publish() fans this out to every backend
            messenger.publish(Channels.ANNOUNCEMENTS, a));
}

Every other backend — subscribe and act:

Messenger messenger = Messengers.get();
if (messenger != null) {
    messenger.subscribe(Channels.ANNOUNCEMENTS, a -> {
        if (!a.server().equals(serverName)) {       // ignore our own echo
            new it.mycraft.powerlib.chat.Message("&e" + a.message())
                    .broadcast();                   // see the Messages page
        }
    });
}

Channel names and codecs must be identical on both ends. The cleanest way to guarantee that is a shared module both the proxy plugin and the backend plugin depend on; copy-pasting the Channels / codec classes works too, as long as they stay in sync.


Gotchas

  • Bukkit needs an online player. BukkitMessageTransport.send carries the frame on the first online player. With zero players online the frame is dropped — there is no buffering and no retry. Don't rely on a backend's startup publish reaching the proxy before anyone joins; send it in response to player activity, or re-send on the first join.
  • Messengers.get() can be null. No registered transport ⇒ null (e.g. tests, or a platform without messaging). Always null-check before publish / subscribe.
  • No delivery guarantees. Plugin messaging is fire-and-forget over the player connection. There is no ack, no redelivery, and a frame can be lost if the carrier disconnects mid-send. Treat messages as best-effort; if a backend must converge on state, make the payload idempotent or carry a version/sequence in the DTO and reconcile on receipt.
  • Ordering is per-link, not global. Frames over one connection arrive in order, but a proxy send fans out to many servers independently and different backends publish through different players — so there is no global total order across the cluster. Never assume message A from server X was processed before message B from server Y.
  • Broadcast, not unicast. A proxy publish reaches every backend, including, after a relay, the original sender. Filter on a payload field (server id, target) when only some receivers should act, and guard against processing your own echo (see the example).
  • Names are matched verbatim. A mismatched channel name doesn't error — it silently never delivers. Centralize Channel declarations.
  • One codec per channel, both ends. If the proxy encodes JSON and a backend decodes with a different codec, decode throws inside dispatch. Keep the Codec paired with the Channel declaration and shared.
  • powerlib:main is reserved. All PowerLib messaging shares this single MC channel; don't register or hijack it from other plugin-messaging code. Add logical channels via new Channel names, never new MC channels.

Reference

Channel<T>it.mycraft.powerlib.common.messaging.Channel

Member Signature Description
of static <T> Channel<T> of(String name, Codec<T> codec) Declares a channel with the given logical name and payload codec.
name String name() The logical channel name shared by both ends.

Codec<T>it.mycraft.powerlib.common.messaging.Codec

Member Signature Description
encode byte[] encode(T value) Serialize a payload to bytes.
decode T decode(byte[] data) Deserialize bytes back to a payload.
string static Codec<String> string() Built-in UTF-8 string codec.

Messengerit.mycraft.powerlib.common.messaging.Messenger

Member Signature Description
publish <T> void publish(Channel<T> channel, T payload) Encode payload with the channel's codec and send it.
subscribe <T> void subscribe(Channel<T> channel, Consumer<T> handler) Register a handler invoked with every decoded payload received on the channel. Multiple handlers per channel are allowed.

Messengersit.mycraft.powerlib.common.messaging.Messengers

Member Signature Description
get static Messenger get() The shared messenger over the platform transport, or null if no transport is registered.

Framingit.mycraft.powerlib.common.messaging.Framing

Member Signature Description
Frame record Frame(String channel, byte[] data) A decoded frame: logical channel name + raw payload.
frame static byte[] frame(String channel, byte[] data) Pack a logical channel name and payload into one frame.
parse static Frame parse(byte[] frame) Decode a frame produced by frame(...).

MessageTransport (SPI) — it.mycraft.powerlib.common.messaging.MessageTransport

Member Signature Description
send void send(String channel, byte[] data) Send a raw frame on the given logical channel.
listen void listen(BiConsumer<String, byte[]> handler) Register the single handler for inbound frames (called once by Messenger).

Built-in implementations, registered via META-INF/services: BukkitMessageTransport, BungeeMessageTransport, VelocityMessageTransport — all on MC channel powerlib:main.


See also: Configuration for loading channel names and other settings from YAML · Scheduler for running subscribe handlers' work back on the main thread · Messages for formatting the text a received payload becomes.

Clone this wiki locally