Skip to content

EventBus

EventBus is the pub/sub primitive shared between modules and plugins. Modules access it through ModuleContext.events(); plugins through CloudPluginContext.events(). Both sides program against the same interface, me.prexorjustin.prexorcloud.api.event.EventBus.

This page documents every method on the interface, the event type hierarchy, the three concrete implementations and how their dispatch differs, and the full table of first-class event records.

Type hierarchy

CloudEvent (interface)
├── PlayerConnectedEvent, InstanceCrashedEvent, … (record, events sub-package)
└── CustomCloudEvent (record) — runtime-defined module/plugin events

Every event is a CloudEvent. The interface declares one method:

public interface CloudEvent {
String type();
}

type() returns a stable string identifier used for dynamic dispatch and SSE streaming. First-class events return SCREAMING_SNAKE_CASE ("PLAYER_CONNECTED"); custom events use the "MODULE:ACTION" convention ("CHAT:MESSAGE").

First-class events live in me.prexorjustin.prexorcloud.api.event.events and are Java records. Their components are public accessors (event.group(), event.exitCode()). See first-class events for the full list.

CustomCloudEvent

For events not known at compile time, publish a CustomCloudEvent:

public record CustomCloudEvent(
String type, String source, Map<String, Object> payload, Instant timestamp)
implements CloudEvent { }
ComponentTypeNotes
typeStringRequired. Non-null. Use "MODULE:ACTION" format.
sourceStringRequired. Non-null. Originator: instance ID, module name, etc.
payloadMap<String, Object>Arbitrary key-value data. null is normalized to Map.of().
timestampInstantWhen created. null is normalized to Instant.now().

A two-argument convenience constructor defaults the timestamp:

events.publish(new CustomCloudEvent(
"CHAT:MESSAGE",
"lobby-1",
Map.of("player", "Steve", "text", "hi")));

Subscribe to custom events by their type string with subscribeByType, not by Class.

Interface methods

on

<T extends CloudEvent> EventSubscriptionBuilder<T> on(Class<T> eventType);

Begin a fluent subscription. Returns an EventSubscriptionBuilder<T> you attach filters to before subscribing. Recommended form.

ParameterTypeDescription
eventTypeClass<T>The event class to subscribe to.
EventSubscription sub = events.on(PlayerConnectedEvent.class)
.filter(e -> e.group().equals("lobby"))
.subscribe(e -> LOG.info("{} joined lobby", e.name()));

subscribe

<T extends CloudEvent> EventSubscription subscribe(Class<T> eventType, EventHandler<T> handler);

Subscribe without a filter. Equivalent to on(eventType).subscribe(handler).

ParameterTypeDescription
eventTypeClass<T>The event class to subscribe to.
handlerEventHandler<T>Callback invoked for each event.

Returns an EventSubscription handle.

EventSubscription sub = events.subscribe(InstanceCrashedEvent.class, e ->
LOG.warn("instance {} crashed exit={} class={}",
e.instanceId(), e.exitCode(), e.classification()));
sub.unsubscribe(); // stop receiving

subscribeByType

EventSubscription subscribeByType(String type, EventHandler<CustomCloudEvent> handler);

Subscribe to CustomCloudEvent instances whose type() matches the given string exactly. Use this for dynamic event types whose Java class is not visible at compile time. The match is exact-string; there is no prefix or wildcard matching.

ParameterTypeDescription
typeStringExact type() string, e.g. "CHAT:MESSAGE".
handlerEventHandler<CustomCloudEvent>Callback for each matching custom event.

Only CustomCloudEvent instances are delivered here — first-class events never match a subscribeByType registration even if their type() string is identical.

EventSubscription sub = events.subscribeByType("VOTIFIER:VOTE", e -> {
String voter = (String) e.payload().get("username");
LOG.info("vote from {}", voter);
});

subscribeAll

EventSubscription subscribeAll(EventHandler<CloudEvent> handler);

Catch-all. The handler receives every published event regardless of type. Every publish iterates all catch-all handlers, so the cost is paid on each event — register few.

ParameterTypeDescription
handlerEventHandler<CloudEvent>Callback invoked for every event.
EventSubscription sub = events.subscribeAll(e ->
LOG.debug("event {}", e.type()));

publish

void publish(CloudEvent event);

Fan out an event to matching subscribers. Dispatch order within a publish is: class handlers, then type-string handlers (only when the event is a CustomCloudEvent), then catch-all handlers. Threading and buffering depend on the implementation — see dispatch semantics.

ParameterTypeDescription
eventCloudEventThe event to publish.
events.publish(new GroupCreatedEvent("survival"));

EventSubscriptionBuilder

Returned by on. Two methods, fluent.

public interface EventSubscriptionBuilder<T extends CloudEvent> {
EventSubscriptionBuilder<T> filter(Predicate<T> predicate);
EventSubscription subscribe(EventHandler<T> handler);
}

filter

EventSubscriptionBuilder<T> filter(Predicate<T> predicate);

Attach a predicate. Only events for which the predicate returns true reach the handler. Multiple filter calls are ANDed — all predicates must pass.

events.on(InstanceStateChangedEvent.class)
.filter(e -> e.group().equals("survival"))
.filter(e -> e.newState() == InstanceState.RUNNING)
.subscribe(e -> LOG.info("{} is up", e.instanceId()));

subscribe

EventSubscription subscribe(EventHandler<T> handler);

Complete the subscription and start receiving events. Returns the EventSubscription handle.

EventHandler

@FunctionalInterface
public interface EventHandler<T extends CloudEvent> {
void handle(T event);
}

A functional interface — pass a lambda or method reference. Exceptions thrown from handle are caught and logged by the bus; they do not abort the publish or affect other handlers (see dispatch semantics). There is no checked exception in the signature; wrap checked exceptions yourself.

EventSubscription

public interface EventSubscription {
void unsubscribe();
}

The handle returned by every subscribe* call. Call unsubscribe() to remove the handler from the bus. Hold the handle for the lifetime you want the subscription to live; on the daemon bus, dropping the last subscriber for an event type also unregisters that type from the controller (see daemon forwarding).

EventSubscription sub = events.subscribe(NodeConnectedEvent.class, this::onNode);
// in onStop():
sub.unsubscribe();

Dispatch semantics

There is one interface, three concrete implementations. They share the dispatch order (class → type-string → catch-all) and the catch-exceptions-and-log contract, but differ in threading.

ImplementationWhereThreadingHandler exception
controller.event.EventBusController in-processAsync — each handler forked on a virtual thread via StructuredTaskScope; publish returns before handlers runCaught, logged at ERROR
DaemonEventBusDaemonAsync — each handler dispatched on Thread.startVirtualThreadCaught, logged at WARN
CloudEventBusImplPlugin platform adapters (Paper/Velocity/Fabric/…)Synchronous — handlers run on the publishing thread, in orderCaught, logged at WARN

Consequences for handler code, all implementations:

  • A handler throwing does not stop the publish or other handlers.
  • No buffering or coalescing — there is no queue, retry, or replay.
  • On the controller and daemon, do not assume ordering between handlers of one publish, and do not assume the handler has run when publish returns. On the plugin adapter, handlers run synchronously in registration order on the caller’s thread.
  • Keep handlers fast and non-blocking on the plugin adapter; a slow handler there blocks the publisher.

Daemon forwarding

The daemon bus (DaemonEventBus) is local pub/sub plus controller registration. When a daemon module subscribes to a Class<? extends CloudEvent> and no other local subscriber for that class exists yet, the bus sends an EventSubscribe message (daemon-service.proto) naming the fully-qualified class name. The controller-side forwarder then pushes matching events back as ModuleEvent messages, which the daemon bus deserializes (via the daemon’s classloader) and re-publishes locally.

When the last local subscriber for a class unsubscribes, the daemon sends EventUnsubscribe so the controller stops forwarding. On gRPC reconnect the daemon re-sends EventSubscribe for every currently subscribed class so a stream blip does not desync the forwarder.

Inbound ModuleEvent payloads whose class name is not on the daemon’s classpath, or does not implement CloudEvent, are logged at WARN and dropped.

First-class events

All in me.prexorjustin.prexorcloud.api.event.events. Each is a record; the components listed are the public accessors. The type() column is the value returned by type() (used for SSE and subscribeByType on the controller bus).

Player

Recordtype()Components
PlayerConnectedEventPLAYER_CONNECTEDUUID uuid, String name, String instanceId, String group
PlayerDisconnectedEventPLAYER_DISCONNECTEDUUID uuid, String name, String instanceId, String group
PlayerTransferEventPLAYER_TRANSFERUUID uuid, String name, String fromInstanceId, String toInstanceId
PlayerJourneyEventPLAYER_JOURNEYPlayerJourneyEntry entry

Instance

Recordtype()Components
InstanceStateChangedEventINSTANCE_STATE_CHANGEDString instanceId, String group, String nodeId, InstanceState oldState, InstanceState newState
InstanceCrashedEventINSTANCE_CRASHEDString instanceId, String group, String nodeId, int exitCode, String classification, List<String> logTail, long uptimeMs
InstanceDrainingEventINSTANCE_DRAININGString instanceId, String group, String nodeId
InstanceConsoleOutputEventINSTANCE_CONSOLE_OUTPUTString instanceId, String line, long timestampMs
InstanceMetricsUpdatedEventINSTANCE_METRICSString instanceId, String group, double tps1m, double tps5m, double tps15m, double msptAvg, long heapUsedMb, long heapMaxMb, long gcCollections, long gcTimeMs, int threadCount, int playerCount, int maxPlayers, int worldCount, long totalEntities, long totalChunks, List<WorldSnapshot> worlds, String serverVersion, int pluginCount

Group

Recordtype()Components
GroupCreatedEventGROUP_CREATEDString groupName
GroupUpdatedEventGROUP_UPDATEDString groupName
GroupDeletedEventGROUP_DELETEDString groupName
GroupAggregatesUpdatedEventGROUP_AGGREGATES_UPDATEDString groupName, int runningInstances, int totalPlayers
GroupMaintenanceChangedEventGROUP_MAINTENANCE_CHANGEDString groupName, boolean maintenance, String message
GroupCrashLoopEventGROUP_CRASH_LOOPString group, int crashCount, Instant windowStart

Node

Recordtype()Components
NodeConnectedEventNODE_CONNECTEDString nodeId, String sessionId, Instant timestamp
NodeDisconnectedEventNODE_DISCONNECTEDString nodeId, String reason, Instant timestamp
NodeStatusUpdatedEventNODE_STATUSString nodeId, double cpuUsage, long usedMemoryMb, long totalMemoryMb, Instant lastHeartbeatAt
NodeCacheStatusUpdatedEventNODE_CACHE_STATUSString nodeId, long totalSizeBytes, Instant timestamp
NodeHeartbeatStaleEventNODE_HEARTBEAT_STALEString nodeId, int missedPongs, Instant lastHeartbeatAt
NodeHeartbeatResumedEventNODE_HEARTBEAT_RESUMEDString nodeId, Instant lastHeartbeatAt
NodeDrainRequestedEventNODE_DRAIN_REQUESTEDString nodeId, boolean shutdownAfterDrain, int drainTimeoutSeconds, String kickMessage, Instant timestamp
NodeDrainCompletedEventNODE_DRAIN_COMPLETEDString nodeId, Instant timestamp

Deployment

Recordtype()Components
DeploymentCreatedEventDEPLOYMENT_CREATEDString groupName, int revision, String strategy
DeploymentCompletedEventDEPLOYMENT_COMPLETEDString groupName, int revision, String outcome

Module and capability

Recordtype()Components
ModuleLoadedEventMODULE_LOADEDString moduleName, boolean hasFrontend
ModuleUnloadedEventMODULE_UNLOADEDString moduleName
ModuleFrontendReloadedEventMODULE_FRONTEND_RELOADEDString moduleName, String contentHash
CapabilityRegisteredEventCAPABILITY_REGISTEREDString capabilityId, String version, String moduleId
CapabilityUnregisteredEventCAPABILITY_UNREGISTEREDString capabilityId, String moduleId
CapabilityProviderChangedEventCAPABILITY_PROVIDER_CHANGEDString capabilityId, String moduleId, String fromVersion, String toVersion

Template, cluster, maintenance, choreography

Recordtype()Components
TemplateUpdatedEventTEMPLATE_UPDATEDString templateName, String oldHash, String newHash
ClusterConfigChangedEventCLUSTER_CONFIG_CHANGEDint version, int parentVersion, String mutator, String action
MaintenanceUpdatedEventMAINTENANCE_UPDATEDboolean globalEnabled, String message
ChoreographyOverlayActivatedEventCHOREOGRAPHY_OVERLAY_ACTIVATEDString eventName, String group, Instant activeUntil
ChoreographyOverlayDeactivatedEventCHOREOGRAPHY_OVERLAY_DEACTIVATEDString eventName, String group, String reason

Worked example: session aggregator

A module service that records player sessions. The constructor takes its dependency (constructor injection); the entrypoint’s onStart calls register(context.events()).

public final class SessionAggregator {
private static final Logger LOG = LoggerFactory.getLogger(SessionAggregator.class);
private final StatsRepository repo;
public SessionAggregator(StatsRepository repo) {
this.repo = repo;
}
public void register(EventBus events) {
events.on(PlayerConnectedEvent.class)
.subscribe(this::onConnected);
events.on(PlayerDisconnectedEvent.class)
.filter(e -> e.group().equals("survival"))
.subscribe(this::onDisconnected);
}
private void onConnected(PlayerConnectedEvent event) {
LOG.info("session start: {} on {}", event.uuid(), event.group());
repo.recordJoin(event);
}
private void onDisconnected(PlayerDisconnectedEvent event) {
repo.recordLeave(event);
}
}

See also