A P I

The MythicProxy API lets another plugin on a Paper backend use the connection that MythicProxy keeps to the proxy:

  • Channels send bytes to another backend.
  • Leases let one backend at a time hold a named lock.
  • Blobs store named byte arrays on the proxy.

This page is for plugin developers. The API exists on the backends only, has no commands, permissions or events, and also works without MythicMobs Premium.


Dependency

The API is the mythicproxy-common module, io.lumine:mythicproxy-common:0.1.0, compiled for Java 21. The build publishes it to no remote repository, so install it into your local Maven repository by running gradlew :common:publishToMavenLocal in the MythicProxy source that matches the jar on your servers.

Do not shade it into your plugin jar. Use compileOnly (Gradle) or the provided scope (Maven). A shaded copy is a different class from the one MythicProxy registers, so the service lookup returns null.

Maven

<dependency>
    <groupId>io.lumine</groupId>
    <artifactId>mythicproxy-common</artifactId>
    <version>0.1.0</version>
    <scope>provided</scope>
</dependency>

Gradle (Groovy)

repositories {
    mavenLocal()
}

dependencies {
    compileOnly 'io.lumine:mythicproxy-common:0.1.0'
}

Gradle (Kotlin)

repositories {
    mavenLocal()
}

dependencies {
    compileOnly("io.lumine:mythicproxy-common:0.1.0")
}

plugin.yml

Add depend: [MythicProxy]. If your plugin should also run without MythicProxy, use softdepend: [MythicProxy] instead, check getServer().getPluginManager().isPluginEnabled("MythicProxy") first, and keep every use of the API in a class that you create only after that check. Without MythicProxy the API classes are missing, and code that refers to them throws NoClassDefFoundError.


Getting the API

MythicProxy registers io.lumine.mythicproxy.common.api.MythicProxyNetwork as a Bukkit service when it enables. Look it up and check for null:

package com.example.myplugin;

import io.lumine.mythicproxy.common.api.MythicProxyNetwork;
import org.bukkit.Bukkit;
import org.bukkit.plugin.java.JavaPlugin;

public final class MyPlugin extends JavaPlugin {

    private MythicProxyNetwork network;

    @Override
    public void onEnable() {
        network = Bukkit.getServicesManager().load(MythicProxyNetwork.class);
        if (network == null) {
            getLogger().warning("MythicProxy is not available");
        }
    }
}
  • load returns null when MythicProxy did not enable, for example because it found no secret or an entry in proxies has a port that is not a number.
  • The service exists before the backend connects to a proxy. isConnected() stays false until a link is up, and there is no connect or disconnect event. See How Backends Find the Proxy for when a backend connects.
Method Description
String serverName() The name of this backend on the proxy: its server-name, or empty until a proxy has named it. Other backends send channel messages to this name, and leases are held under it
boolean isConnected() true while at least one proxy link is up

Use only the methods on this page and the records LeaseReply and LeaseState. The public constructor and the handle method are for MythicProxy itself.


Channels

A channel message goes from one backend to another through the proxy, which passes it on and stores nothing. Nothing confirms delivery and nothing is retried.

Method Description
boolean send(String target, String channel, byte[] data) Sends data on channel to the backend named target. Returns false when nothing was sent, see Limits and Connection
void onChannel(String channel, BiConsumer<String, byte[]> handler) Sets the handler for channel. It receives the name of the sending backend and the data
void removeChannel(String channel) Removes the handler for channel
  • target must match the receiving backend's serverName() exactly, letter case included. There is no broadcast: call send once per backend.
  • A message for a backend that is not connected to the proxy is dropped, and send still returns true. A backend never receives its own messages.
  • A backend has one handler per channel name. Calling onChannel again replaces the handler, also one that another plugin registered, so start your channel names with your plugin name. A message for a channel without a handler is ignored.
  • Handlers run on MythicProxy's connection thread, see Threads.

Channel Example

requestReload("survival", "weapons") makes the backend named survival log the request. The handler moves its work to the global region scheduler.

package com.example.myplugin;

import io.lumine.mythicproxy.common.api.MythicProxyNetwork;
import org.bukkit.Bukkit;
import org.bukkit.plugin.java.JavaPlugin;

import java.nio.charset.StandardCharsets;

public final class ReloadRelay extends JavaPlugin {

    private static final String CHANNEL = "myplugin:reload";

    private MythicProxyNetwork network;

    @Override
    public void onEnable() {
        network = Bukkit.getServicesManager().load(MythicProxyNetwork.class);
        if (network == null) {
            getLogger().warning("MythicProxy is not available");
            return;
        }
        network.onChannel(CHANNEL, (origin, data) -> {
            String shop = new String(data, StandardCharsets.UTF_8);
            Bukkit.getGlobalRegionScheduler().run(this, task -> reload(origin, shop));
        });
    }

    @Override
    public void onDisable() {
        if (network != null) {
            network.removeChannel(CHANNEL);
        }
    }

    public boolean requestReload(String server, String shop) {
        return network != null && network.send(server, CHANNEL, shop.getBytes(StandardCharsets.UTF_8));
    }

    private void reload(String origin, String shop) {
        getLogger().info("Reloading " + shop + " on request of " + origin);
    }
}

Leases

A lease is a named lock that one backend holds until it expires or is released. Leases are stored in the proxy's database, so they survive a proxy restart, and a backend that disconnects or crashes keeps its leases until they expire.

Method Description
boolean lease(String name, long ttlMillis, boolean release, long tag) With release set to false, takes or renews the lease name for ttlMillis milliseconds. With release set to true, releases it and ignores ttlMillis. The reply carries tag back unchanged. Returns false when nothing was sent
void onLease(Consumer<LeaseReply> listener) Adds a listener for lease replies. Every listener receives every reply this backend gets, also replies meant for other plugins, so check state().name(), and tag() if you send several kinds of requests. A listener cannot be removed

What the Proxy Does

Request Situation Result
Take You hold the lease, expired or not You keep it with the same token and a new expiry
Take Nobody holds it, or another backend holds it and its time has run out You hold it, the token goes up by one, new expiry
Take Another backend holds it and its time has not run out Nothing changes
Release You hold the lease Nobody holds it, same token, expiresAt is 0
Release You do not hold it Nothing changes
  • The new expiry is the proxy's clock plus ttlMillis, so renewing does not add to the old expiry. A ttlMillis of 0 or less expires at once. Do not pass Long.MAX_VALUE for a lease that never expires: the sum overflows and the lease counts as expired.
  • The holder is a backend, by server name, not a plugin or a player. Two plugins on one backend that use the same lease name share it, and a restarted backend with the same name still holds its leases.
  • There is no way to read a lease without asking for it.
  • The proxy answers only the backend that asked. Other backends are not told when a lease changes hands, and a backend that lost its lease finds out from the reply to its next request.
  • When the proxy cannot process a request, for example because its database fails, it logs a warning and sends no reply. Use a timeout when you wait for one.

Reply Records

Both records are in io.lumine.mythicproxy.common.lease. Read a field with the accessor of the same name, for example reply.state().token().

LeaseReply(LeaseState state, long tag): state is the lease after the proxy handled your request, and tag is the value you passed.

LeaseState(String name, String holder, long token, long expiresAt):

  • name: the name of the lease.
  • holder: the server name of the holder, or null when the lease was never held or was released. An expired lease still names its last holder.
  • token: the fencing token, see Fencing Token.
  • expiresAt: when the lease expires, in epoch milliseconds on the proxy's clock. 0 after a release or when it was never held.

Fencing Token

The token tells a newer holder from an older one. Use it as the fence of a blob.

  • The first time a lease is taken its token is 1. The token goes up by one each time a backend takes the lease while it is not the stored holder, which also happens after a release, even when the same backend takes it again.
  • Renewing keeps the token, also when your lease had expired and nobody else took it.
  • The token never goes down, and every reply carries the current token, also a reply that refuses your request.

Expiry and Clocks

expiresAt comes from the proxy's clock, so do not compare it with your own clock. Instead, put the time you sent the request in tag: no other backend can take the lease before that time plus ttlMillis, so stop using the lease at that moment, measured on your own clock. To keep a lease, renew it from a repeating task that runs more often than ttlMillis. The Blob Example uses a lease.


Blobs

A blob is a named byte array stored in the proxy's database. Backends read it through the proxy, it survives a proxy restart, and it cannot be deleted.

Method Description
boolean putBlob(String name, long fence, byte[] data) Stores data under name unless the fence already stored for that name is higher than fence. There is no reply. Returns false when nothing was sent
boolean getBlob(String name) Asks for the blob name. The answer goes to the onBlob listeners. Returns false when nothing was sent
void onBlob(BiConsumer<String, byte[]> listener) Adds a listener for blob replies. It receives the name and the data, or null when nothing is stored under that name. Every listener receives every reply this backend gets, so check the name. A listener cannot be removed
  • The first write for a name is always stored. A later write replaces it when its fence is greater than or equal to the stored fence, otherwise it is ignored without notice. The stored fence never goes down.
  • The proxy does not look at leases. To let only the current lease holder write, take the lease and use its token as the fence: after a newer holder has written with its higher token, writes with an older token are ignored.
  • The proxy handles one backend's requests in order and answers in the same order, so a getBlob sent after a putBlob sees that write. Use it to confirm a write.
  • As with leases, a request the proxy cannot process gets no reply.

Blob Example

save asks for the lease. When the reply says this backend holds it, the class writes the blob with the lease token as fence and releases the lease. When another backend holds it, nothing is written. loaded() returns the data of the last blob reply, null when nothing is stored or no reply has arrived.

package com.example.myplugin;

import io.lumine.mythicproxy.common.api.MythicProxyNetwork;
import io.lumine.mythicproxy.common.lease.LeaseReply;
import io.lumine.mythicproxy.common.lease.LeaseState;

import java.util.concurrent.atomic.AtomicReference;

public final class SharedSettings {

    private static final String LEASE = "myplugin:settings-writer";
    private static final String BLOB = "myplugin:settings";

    private final MythicProxyNetwork network;
    private final AtomicReference<byte[]> pending = new AtomicReference<>();
    private volatile byte[] loaded;

    public SharedSettings(MythicProxyNetwork network) {
        this.network = network;
        network.onLease(this::onLease);
        network.onBlob(this::onBlob);
    }

    public boolean save(byte[] data) {
        pending.set(data);
        return network.lease(LEASE, 10_000L, false, 0L);
    }

    public boolean load() {
        return network.getBlob(BLOB);
    }

    public byte[] loaded() {
        return loaded;
    }

    private void onLease(LeaseReply reply) {
        LeaseState lease = reply.state();
        if (!lease.name().equals(LEASE) || !network.serverName().equals(lease.holder())) {
            return;
        }
        byte[] data = pending.getAndSet(null);
        if (data == null) {
            return;
        }
        network.putBlob(BLOB, lease.token(), data);
        network.lease(LEASE, 0L, true, 0L);
    }

    private void onBlob(String name, byte[] data) {
        if (name.equals(BLOB)) {
            loaded = data;
        }
    }
}

Threads

  • Channel handlers and lease and blob listeners run on the thread that reads from the proxy connection, not on the server thread or a region thread. Hand work that uses the Bukkit API to a scheduler, as the Channel Example does.
  • Keep them short: while one runs, no other message from that proxy is handled, variable updates included. With several proxies each has its own thread, so callbacks can run at the same time.
  • When a callback throws, MythicProxy logs Failed to handle <TYPE> from <origin> with the stack trace and keeps the connection. The listeners added after the one that threw do not receive that reply, so catch exceptions inside your listeners.
  • You can register handlers and call send, lease, putBlob and getBlob from any thread.

Limits and Connection

  • No proxy connected: send, lease, putBlob and getBlob return false at once. Nothing is queued, retried or logged, unlike variable writes (see When a Proxy Is Down).
  • true is not delivery: it only means the message was handed to the connection. If the connection fails right after, the message is lost without notice.
  • Size: a message can be at most 1 MiB, see Limits. Data travels Base64 encoded, which makes it a third larger, so one call can carry about 786,000 bytes. A call over the limit returns false, sends nothing and logs nothing.
  • Arguments are not checked: null data makes send and putBlob throw a NullPointerException before anything is sent. A null channel or name is sent anyway, the call returns true, and the receiving side logs a warning and ignores it.
  • Names: channel, lease and blob names are shared by every plugin on every backend, so start them with your plugin name. Keep lease and blob names to 191 characters or fewer, the size of the name column with MySQL storage.
  • Proxy replies: a backend whose name equals a proxy's proxy-id ignores that proxy's lease and blob replies, see Server Names.

Multiple Proxies

  • Each call goes to the same proxy that receives the backend's variable changes, see Multiple Proxies. The reply comes from that proxy.
  • Leases and blobs live in the database of the proxy that handled the request. With the default SQLite storage every proxy has its own file, so two proxies can give the same lease to two backends, and a blob written through one proxy is missing on the other. Point every proxy at the same MySQL database, see Storage. Each proxy judges lease expiry with its own clock.
  • A proxy passes a channel message only to the backends connected to it, so connect every backend to every proxy.
Updated Oct 29, 2026