Skip to content

Module SDK

A module is a backend extension that runs inside the controller (and optionally inside each daemon). It ships as one shaded jar plus a module.yaml manifest. The host loads the jar in its own JVM and drives the entrypoint through a lifecycle, handing each hook a ModuleContext that exposes storage, events, capabilities, scheduling, HTTP, and JSON.

All SDK types live under me.prexorjustin.prexorcloud.api.module in the cloud-api artifact.

What you’ll learn

  • The two entrypoint contracts: PlatformModule (controller) and DaemonModule (per-node).
  • The shared ModuleContext surface.
  • The orthogonal subsystems: events, capabilities, storage, REST, scheduling.
  • The on-disk module.yaml schema.

SDK pages

PageSurface
PlatformModuleController-side lifecycle, REST registration, capability handles, health.
DaemonModuleDaemon-side lifecycle plus per-instance hooks.
ModuleContextShared context: identity, storage, events, scheduler, HTTP, JSON, capabilities.
EventBusSubscribing to and publishing cluster events.
Capability APIThe provides / requires graph and CapabilityHandle.
Storage APIMongo ModuleDataStore and Redis PlatformRedisStorage.
REST routesonRegisterRoutes and the per-module route dispatcher.
module.yamlManifest schema.

Entrypoints

A module implements one of two contracts depending on which host runs it. The manifest’s hosts list picks the host(s); each host listed must have a matching backend entrypoint.

ContractPackageHostStoragePer-instance hooks
PlatformModuleme.prexorjustin.prexorcloud.api.module.platformcontrollerMongo + Redisno
DaemonModuleme.prexorjustin.prexorcloud.api.module.platformdaemon (per node)none — findMongoStorage() returns Optional.empty()yes

A module that needs both sides declares hosts: [controller, daemon], ships a PlatformModule under backend.controller.entrypoint and a DaemonModule under backend.daemon.entrypoint. The two halves run in different processes and share no heap state; they communicate through events forwarded from the controller bus to the daemon.

PlatformModule lifecycle hooks

All hooks are default (no-op) so a module overrides only what it needs. Every hook except onRegisterRoutes receives a ModuleContext and may throw checked exceptions.

MethodSignatureWhen
onLoadvoid onLoad(ModuleContext context) throws ExceptionAfter the jar loads, before routes and onStart. Wire up repositories and services here.
onRegisterRoutesvoid onRegisterRoutes(RouteRegistrar registrar)Once, after onLoad, before onStart. Register REST routes.
onStartvoid onStart(ModuleContext context) throws ExceptionModule transitions to active.
onStopvoid onStop(ModuleContext context) throws ExceptionModule is stopping.
onUnloadvoid onUnload(ModuleContext context) throws ExceptionJar is being unloaded. Release references.
onUpgradevoid onUpgrade(ModuleContext context) throws ExceptionA newer version replaced a previous one; context.previousVersion() carries the old version.
onReloadvoid onReload(ModuleContext context) throws ExceptionHot-reload fast path (reloadable: true). The only hook called on reload — onStop/onUnload are skipped, so the new instance must re-arm scheduler tasks and rebuild caches itself.
capabilityHandlesList<CapabilityHandle<?>> capabilityHandles()Polled after activation. Returns the handles this module exports. Default List.of().
healthCheckModuleHealth healthCheck()Polled on a fixed cadence for active modules. Must be cheap and non-blocking. Default ModuleHealth.unknown().

DaemonModule lifecycle and instance hooks

DaemonModule shares onLoad/onStart/onStop/onUnload/onUpgrade and capabilityHandles with PlatformModule (same signatures), and adds per-instance hooks for instances running on the local node:

MethodSignatureWhen
onInstanceStartingvoid onInstanceStarting(InstanceSpec spec) throws ExceptionPre-launch. Mutate spec.jvmArgs() or spec.env() to inject flags. Throwing aborts the start.
onInstanceStartedvoid onInstanceStarted(InstanceHandle handle) throws ExceptionAfter the process is spawned and the daemon has a PID.
onInstanceStoppingvoid onInstanceStopping(InstanceHandle handle) throws ExceptionBefore the daemon stops the process.
onInstanceStoppedvoid onInstanceStopped(InstanceHandle handle, ExitInfo exit) throws ExceptionAfter the process exits (clean or crashed).

Daemon capability handles are node-local; cross-node visibility is out of scope.

ModuleContext at a glance

ModuleContext is the single argument to every lifecycle hook. Full detail is on the ModuleContext page; the surface:

MethodReturnsPurpose
manifest()PlatformModuleManifestThe parsed module.yaml.
jarPath()PathLocation of the loaded module jar.
previousVersion()String"" on fresh install; the prior version when upgrading.
isUpgrade()booleantrue when previousVersion() is non-blank.
host()ModuleHostCONTROLLER or DAEMON.
findCapability(id, type)Optional<T>Resolve a requires capability; empty if unbound.
requireCapability(id, type)TResolve or throw.
findMongoStorage()Optional<ModuleDataStore>Mongo store if storage.mongo: true.
requireMongoStorage()ModuleDataStoreMongo store or throw.
findRedisStorage()Optional<PlatformRedisStorage>Redis store if storage.redis: true.
requireRedisStorage()PlatformRedisStorageRedis store or throw.
events()EventBusCluster-wide event bus.
logger()org.slf4j.LoggerPre-namespaced module:<id>.
scheduler()TaskSchedulerModule-owned async work; tasks cancelled on stop.
httpClient()java.net.http.HttpClientShared outbound client for webhooks and third-party APIs.
json()com.fasterxml.jackson.databind.ObjectMapperjava-time, ISO-8601, NON_NULL, lenient on unknown properties.

Hello-world platform module

A minimal module that owns one Mongo collection, registers one GET route, and logs through SLF4J:

package com.example.hello;
import java.util.Map;
import me.prexorjustin.prexorcloud.api.module.platform.ModuleContext;
import me.prexorjustin.prexorcloud.api.module.platform.PlatformModule;
import me.prexorjustin.prexorcloud.api.module.rest.RouteRegistrar;
public final class HelloModule implements PlatformModule {
private ModuleContext context;
@Override
public void onLoad(ModuleContext context) {
this.context = context;
context.requireMongoStorage().ensureCollection("greetings");
}
@Override
public void onRegisterRoutes(RouteRegistrar registrar) {
registrar.get("/greetings", (req, res) -> {
long count = context.requireMongoStorage().count("greetings", null);
res.json(Map.of("count", count));
});
}
@Override
public void onStart(ModuleContext context) {
context.logger().info("hello module started");
}
}
src/main/module/module.yaml
manifestVersion: 1
id: hello
version: 1.0.0
hosts: [controller]
backend:
controller:
entrypoint: com.example.hello.HelloModule
storage:
mongo: true

The route is mounted at /api/v1/modules/hello/greetings. The collection is namespaced under the module’s mod_hello_ prefix (ModuleDataStore.collectionPrefix()).

module.yaml schema

The manifest is parsed into PlatformModuleManifest. CURRENT_MANIFEST_VERSION is 2; MIN_MANIFEST_VERSION is 1. Fields introduced past their schema version (for example capabilities.provides[].deprecatedSince requires v2) are rejected when declared under an older manifestVersion.

FieldTypeRequiredNotes
manifestVersionintyes1 or 2.
idstringyesModule id; namespaces storage, routes, logger.
versionstringyesSemver.
hostslist of controller / daemonnoDefaults to [controller] when omitted.
backend.controller.entrypointstringwhen controller is a hostFQCN of a PlatformModule.
backend.controller.reloadableboolnov2+, default false. Opts into the onReload fast path.
backend.daemon.entrypointstringwhen daemon is a hostFQCN of a DaemonModule.
frontend.sdkVersionintnoDashboard frontend bundle SDK version.
frontend.entrystringnoFrontend entry file (e.g. index.js).
storage.mongoboolnoAllocate a scoped Mongo namespace. Default false.
storage.redisboolnoAllocate a scoped Redis prefix. Default false.
storage.limits.mongoDocumentslongnoDocument cap; requires mongo: true.
storage.limits.redisKeyslongnoKey cap; requires redis: true.
capabilities.provides[]{id, version, deprecatedSince?, removedIn?}noCapabilities offered to other modules. deprecatedSince/removedIn are v2+.
capabilities.requires[]{id, versionRange}noMust resolve before the module reaches active.
extensions[]workload extensionnoIn-MC artifacts shipped with the module; see below.

Workload extensions

extensions[] entries describe in-MC artifacts the module ships (WorkloadExtensionManifest). Each names a target (RuntimeTarget, e.g. server/paper, proxy/velocity, server/bedrock-geyser), an activation policy (ActivationPolicy: explicit-group-attach, default-enabled, always), optional conflicts, and one or more variants. Each variant carries id, mcVersionRange, runtimeApiVersion, artifact, sha256 (use AUTO to compute at build time), and installPath.

extensions:
- id: example-playtime-paper
target: server/paper
activation: explicit-group-attach
variants:
- id: example-playtime-paper
mcVersionRange: "*"
runtimeApiVersion: 1
artifact: extensions/server/paper/example-playtime-paper.jar
sha256: AUTO
installPath: plugins/

Conventions

  • Logging: SLF4J only (org.slf4j.Logger). Use the pre-namespaced context.logger().
  • JSON: use context.json() (Jackson, java-time, ISO-8601, NON_NULL). No Gson, no hand-rolled serialization.
  • Persistence: Mongo via ModuleDataStore, Redis via PlatformRedisStorage. Both are scoped per module.
  • DI: constructor injection only. Build services in onLoad and pass dependencies in.
  • REST: register in onRegisterRoutes; do not retain the RouteRegistrar. Routes mount under /api/v1/modules/{id}/ and are dropped on uninstall or upgrade.

Reference modules

Two first-party modules in the repo exercise the surface end to end:

  • java/cloud-modules/example (example-playtime) — Mongo storage, a REST route set, a ToLongFunction<UUID> capability handle, a worked healthCheck(), and per-runtime workload extensions.
  • java/cloud-modules/stats-aggregator — consumes the prexor.player.journey capability (requires) and provides a leaderboard capability.

Next up