From 7149be1e7f5aebb1469931d96bdc821b320f11b5 Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:19:29 +0700 Subject: [PATCH 01/14] build: replace Spark with Jetty 12 --- build.gradle | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index a686ab5..e853b9c 100644 --- a/build.gradle +++ b/build.gradle @@ -26,16 +26,14 @@ tasks.jar { shadowJar { relocate 'org.bstats', 'me.fredthedoggy.restpapi.libs.bstats' - relocate 'spark', 'me.fredthedoggy.restpapi.libs.spark' + relocate 'org.eclipse.jetty', 'me.fredthedoggy.restpapi.libs.jetty' relocate 'com.google.gson', 'me.fredthedoggy.restpapi.libs.gson' archiveBaseName.set('Restpapi') archiveVersion.set('1.0.5') archiveClassifier.set('') - // Removed manifest block as it's not needed for Bukkit plugins // Explicitly include the main source set output for relocation from sourceSets.main.output - } // Make sure 'build' depends on 'shadowJar' @@ -51,7 +49,9 @@ dependencies { compileOnly 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT' compileOnly 'me.clip:placeholderapi:2.12.3' implementation 'org.bstats:bstats-bukkit:3.0.2' - implementation 'com.sparkjava:spark-core:2.9.4' + implementation('org.eclipse.jetty:jetty-server:12.1.13') { + exclude group: 'org.slf4j', module: 'slf4j-api' + } implementation 'com.google.code.gson:gson:2.11.0' testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' From d11e7cd65fe2c189b1ca92ba0376bebf72a48456 Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:21:17 +0700 Subject: [PATCH 02/14] refactor: serve REST API directly with Jetty 12 --- .../me/fredthedoggy/restpapi/JettyServer.java | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 src/main/java/me/fredthedoggy/restpapi/JettyServer.java diff --git a/src/main/java/me/fredthedoggy/restpapi/JettyServer.java b/src/main/java/me/fredthedoggy/restpapi/JettyServer.java new file mode 100644 index 0000000..ae7ea39 --- /dev/null +++ b/src/main/java/me/fredthedoggy/restpapi/JettyServer.java @@ -0,0 +1,228 @@ +package me.fredthedoggy.restpapi; + +import com.google.gson.Gson; +import me.clip.placeholderapi.PlaceholderAPI; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.pathmap.UriTemplatePathSpec; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.InetAccessHandler; +import org.eclipse.jetty.server.handler.PathMappingsHandler; +import org.eclipse.jetty.server.handler.QoSHandler; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.thread.QueuedThreadPool; +import org.eclipse.jetty.util.thread.VirtualThreadPool; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; + +final class JettyServer { + private static final Gson JSON = new Gson(); + private static final UriTemplatePathSpec SERVER_ROUTE = new UriTemplatePathSpec("/server/{placeholder}"); + private static final UriTemplatePathSpec PLAYER_ROUTE = new UriTemplatePathSpec("/{uuid}/{placeholder}"); + + private final Restpapi plugin; + private final RestConfig config; + private final RequestLimiter limiter; + private final AtomicBoolean accepting = new AtomicBoolean(true); + private final AtomicBoolean stopped = new AtomicBoolean(false); + private Server server; + + JettyServer(Restpapi plugin, RestConfig config) { + this.plugin = plugin; + this.config = config; + limiter = new RequestLimiter(config.rateLimit(), config.rateWindowSeconds()); + } + + void start() { + try { + VirtualThreadPool virtualThreads = new VirtualThreadPool(); + virtualThreads.setName("restpapi-virtual"); + + QueuedThreadPool platformThreads = new QueuedThreadPool(); + platformThreads.setName("restpapi-jetty"); + platformThreads.setVirtualThreadsExecutor(virtualThreads); + + Server jetty = new Server(platformThreads); + jetty.addBean(virtualThreads); + + ServerConnector connector = new ServerConnector(jetty); + connector.setHost(config.bind()); + connector.setPort(config.port()); + jetty.addConnector(connector); + + PathMappingsHandler routes = new PathMappingsHandler(); + routes.addMapping(SERVER_ROUTE, new PlaceholderHandler(SERVER_ROUTE, false)); + routes.addMapping(PLAYER_ROUTE, new PlaceholderHandler(PLAYER_ROUTE, true)); + + Handler routed = new Handler.Sequence(routes, new NotFoundHandler()); + + JsonQoSHandler qos = new JsonQoSHandler(routed); + qos.setMaxRequestCount(config.maxConcurrent()); + qos.setMaxSuspendedRequestCount(0); + qos.setRejectStatusCode(HttpStatus.SERVICE_UNAVAILABLE_503); + + Handler root = qos; + if (!config.allowedIps().isEmpty()) { + JsonInetAccessHandler access = new JsonInetAccessHandler(root); + access.include(config.allowedIps().toArray(String[]::new)); + root = access; + } + + jetty.setHandler(root); + jetty.start(); + server = jetty; + } catch (Exception exception) { + accepting.set(false); + throw new IllegalStateException("Jetty HTTP server could not start", exception); + } + } + + private final class PlaceholderHandler extends Handler.Abstract { + private final UriTemplatePathSpec route; + private final boolean playerRoute; + + private PlaceholderHandler(UriTemplatePathSpec route, boolean playerRoute) { + this.route = route; + this.playerRoute = playerRoute; + } + + @Override + public boolean handle(Request request, Response response, Callback callback) { + if (!"GET".equals(request.getMethod())) { + return writeJson(response, callback, 404, "Invalid URI"); + } + if (!accepting.get() || !plugin.isEnabled()) { + return writeJson(response, callback, 503, "Service Unavailable"); + } + if (!authorized(request.getHeaders().get("Token"))) { + return writeJson(response, callback, 401, "Unauthorized"); + } + + String peer = Request.getRemoteAddr(request); + if (!limiter.allow(peer)) { + return writeJson(response, callback, 429, "Too Many Requests"); + } + + try { + Map params = route.getPathParams(Request.getPathInContext(request)); + String name = params.get("placeholder"); + if (name == null || name.isEmpty() || name.length() > 256 || name.indexOf('%') >= 0) { + return writeJson(response, callback, 400, "Invalid Placeholder"); + } + + OfflinePlayer player = null; + if (playerRoute) { + UUID playerId; + try { + playerId = UUID.fromString(params.get("uuid")); + } catch (IllegalArgumentException | NullPointerException exception) { + return writeJson(response, callback, 400, "Invalid UUID"); + } + + player = Bukkit.getOfflinePlayer(playerId); + if (!player.hasPlayedBefore() && !player.isOnline()) { + return writeJson(response, callback, 400, "Player Has Not Played Before"); + } + } + + String expression = "%" + name + "%"; + String result = PlaceholderAPI.setPlaceholders(player, expression); + if (!accepting.get() || !plugin.isEnabled()) { + return writeJson(response, callback, 503, "Service Unavailable"); + } + + int status = placeholderStatus(expression, result); + return writeJson(response, callback, status, status == 406 ? "Invalid Placeholder" : result); + } catch (Exception exception) { + plugin.getLogger().log(Level.WARNING, "Placeholder lookup failed", exception); + return writeJson(response, callback, 500, "Internal Server Error"); + } + } + } + + boolean authorized(String provided) { + if (provided == null || provided.isEmpty()) return false; + byte[] candidate = provided.getBytes(StandardCharsets.UTF_8); + boolean matched = false; + for (String token : config.tokens()) { + matched |= MessageDigest.isEqual(candidate, token.getBytes(StandardCharsets.UTF_8)); + } + return matched; + } + + static int placeholderStatus(String expression, String result) { + return expression.equals(result) ? 406 : 200; + } + + static String jsonPayload(int status, String message) { + return JSON.toJson(new Lookup(status, message)); + } + + private static boolean writeJson(Response response, Callback callback, int status, String message) { + response.setStatus(status); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, "application/json; charset=UTF-8"); + Content.Sink.write(response, true, jsonPayload(status, message), callback); + return true; + } + + void stop() { + if (!stopped.compareAndSet(false, true)) return; + accepting.set(false); + Server current = server; + server = null; + if (current == null) return; + + try { + current.stop(); + } catch (Exception exception) { + plugin.getLogger().log(Level.WARNING, "Jetty HTTP server did not stop cleanly", exception); + } + } + + private static final class JsonQoSHandler extends QoSHandler { + private JsonQoSHandler(Handler handler) { + super(handler); + } + + @Override + protected void reject(Request request, Response response, Callback callback, int status) { + writeJson(response, callback, status, "Service Busy"); + } + } + + private static final class JsonInetAccessHandler extends InetAccessHandler { + private JsonInetAccessHandler(Handler handler) { + super(handler); + } + + @Override + protected boolean onConditionsNotMet(Request request, Response response, Callback callback) { + return writeJson(response, callback, 403, "Forbidden"); + } + } + + private static final class NotFoundHandler extends Handler.Abstract.NonBlocking { + @Override + public boolean handle(Request request, Response response, Callback callback) { + return writeJson(response, callback, 404, "Invalid URI"); + } + } + + private record Lookup(String status, String message) { + private Lookup(int status, String message) { + this(Integer.toString(status), message); + } + } +} From 5b0b9a64eedd285190f5faee25ef516080b422fe Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:21:53 +0700 Subject: [PATCH 03/14] refactor: manage Jetty lifecycle with virtual-thread executor --- .../fredthedoggy/restpapi/RestPapiLoader.java | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java b/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java index d3523c8..0771028 100644 --- a/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java +++ b/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java @@ -8,11 +8,15 @@ import java.util.Objects; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.logging.Level; public final class RestPapiLoader { private final Restpapi plugin; - private SparkWrapper webServer; + private final ExecutorService lifecycleExecutor = + Executors.newSingleThreadExecutor(Thread.ofVirtual().name("restpapi-lifecycle-", 0).factory()); + private JettyServer webServer; private RestConfig runningConfig; private boolean reloading; private boolean disabled; @@ -35,7 +39,7 @@ void enable() { Objects.requireNonNull(plugin.getCommand("rpapi")).setExecutor(new RestPapiCommand(plugin)); try { RestConfig config = new RestConfig(plugin.getConfig()); - SparkWrapper server = new SparkWrapper(plugin, config); + JettyServer server = new JettyServer(plugin, config); server.start(); webServer = server; runningConfig = config; @@ -57,21 +61,19 @@ synchronized CompletableFuture reload() { return CompletableFuture.completedFuture(false); } reloading = true; - SparkWrapper previous = webServer; + JettyServer previous = webServer; RestConfig previousConfig = runningConfig; - return CompletableFuture.supplyAsync(() -> replace(previous, previousConfig, next)); + return CompletableFuture.supplyAsync(() -> replace(previous, previousConfig, next), lifecycleExecutor); } - private boolean replace(SparkWrapper previous, RestConfig previousConfig, RestConfig next) { + private boolean replace(JettyServer previous, RestConfig previousConfig, RestConfig next) { try { - if (previous != null) { - previous.stop(); - previous.awaitStop(); // Never wait for Spark workers on the Bukkit tick thread. - } + if (previous != null) previous.stop(); synchronized (this) { if (disabled) return false; } - SparkWrapper replacement = new SparkWrapper(plugin, next); + + JettyServer replacement = new JettyServer(plugin, next); replacement.start(); synchronized (this) { if (disabled) { @@ -87,7 +89,7 @@ private boolean replace(SparkWrapper previous, RestConfig previousConfig, RestCo plugin.getLogger().log(Level.SEVERE, "REST reload failed; restoring previous service", exception); if (previousConfig != null && !isDisabled()) { try { - SparkWrapper restored = new SparkWrapper(plugin, previousConfig); + JettyServer restored = new JettyServer(plugin, previousConfig); restored.start(); synchronized (this) { if (disabled) restored.stop(); @@ -109,12 +111,13 @@ private boolean replace(SparkWrapper previous, RestConfig previousConfig, RestCo private synchronized boolean isDisabled() { return disabled; } void disable() { - SparkWrapper server; + JettyServer server; synchronized (this) { disabled = true; server = webServer; webServer = null; } if (server != null) server.stop(); + lifecycleExecutor.shutdown(); } } From 55fe4c3a4af8c53972a35e04932c6f3ed533291d Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:22:09 +0700 Subject: [PATCH 04/14] refactor: remove Spark HTTP wrapper --- .../fredthedoggy/restpapi/SparkWrapper.java | 165 ------------------ 1 file changed, 165 deletions(-) delete mode 100644 src/main/java/me/fredthedoggy/restpapi/SparkWrapper.java diff --git a/src/main/java/me/fredthedoggy/restpapi/SparkWrapper.java b/src/main/java/me/fredthedoggy/restpapi/SparkWrapper.java deleted file mode 100644 index 6ab9ec1..0000000 --- a/src/main/java/me/fredthedoggy/restpapi/SparkWrapper.java +++ /dev/null @@ -1,165 +0,0 @@ -package me.fredthedoggy.restpapi; - -import com.google.gson.Gson; -import me.clip.placeholderapi.PlaceholderAPI; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; -import spark.Request; -import spark.Response; -import spark.Service; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.UUID; -import java.util.concurrent.Semaphore; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.logging.Level; - -import static spark.Service.ignite; - -final class SparkWrapper { - private static final Gson JSON = new Gson(); - private final Restpapi plugin; - private final RestConfig config; - private final Semaphore concurrent; - private final RequestLimiter limiter; - private final AtomicBoolean accepting = new AtomicBoolean(true); - private final AtomicBoolean stopped = new AtomicBoolean(false); - private Service http; - - SparkWrapper(Restpapi plugin, RestConfig config) { - this.plugin = plugin; - this.config = config; - concurrent = new Semaphore(config.maxConcurrent()); - limiter = new RequestLimiter(config.rateLimit(), config.rateWindowSeconds()); - } - - void start() { - Service service = ignite(); - http = service; - AtomicReference startFailure = new AtomicReference<>(); - boolean routesRegistered = false; - try { - service.initExceptionHandler(startFailure::set); - service.untrustForwardHeaders(); - service.ipAddress(config.bind()); - service.port(config.port()); - service.threadPool(32, 4, 30000); - service.get("/server/:placeholder", (request, response) -> handle(request, response, false)); - routesRegistered = true; - service.get("/:uuid/:placeholder", (request, response) -> handle(request, response, true)); - service.notFound((request, response) -> json(response, 404, "Invalid URI")); - service.internalServerError((request, response) -> json(response, 500, "Internal Server Error")); - service.awaitInitialization(); - if (startFailure.get() != null) { - throw new IllegalStateException("HTTP listener could not bind", startFailure.get()); - } - } catch (RuntimeException exception) { - if (routesRegistered) { - stop(); - awaitStop(); - } else { - accepting.set(false); - http = null; - } - throw exception; - } - } - - private String handle(Request request, Response response, boolean playerRoute) { - response.type("application/json"); - if (!accepting.get() || !plugin.isEnabled()) return json(response, 503, "Service Unavailable"); - if (!authorized(request.headers("Token"))) return json(response, 401, "Unauthorized"); - // Use the socket peer; forwarded headers can be forged unless a trusted proxy strips them. - if (!config.allowedIps().isEmpty() && !config.allowedIps().contains(request.ip())) { - return json(response, 403, "Forbidden"); - } - if (!limiter.allow(request.ip())) return json(response, 429, "Too Many Requests"); - if (!concurrent.tryAcquire()) return json(response, 503, "Service Busy"); - - try { - String name = request.params(":placeholder"); - if (name == null || name.isEmpty() || name.length() > 256 || name.indexOf('%') >= 0) { - return json(response, 400, "Invalid Placeholder"); - } - - UUID playerId = null; - if (playerRoute) { - try { - playerId = UUID.fromString(request.params(":uuid")); - } catch (IllegalArgumentException exception) { - return json(response, 400, "Invalid UUID"); - } - } - - String expression = "%" + name + "%"; - OfflinePlayer player = playerId == null ? null : Bukkit.getOfflinePlayer(playerId); - if (player != null && !player.hasPlayedBefore() && !player.isOnline()) { - return json(response, 400, "Player Has Not Played Before"); - } - - // Spark invokes routes on its Jetty worker pool. Keep both OfflinePlayer lookup and - // PlaceholderAPI evaluation on that worker instead of moving the request onto the - // Minecraft main thread. Placeholder expansions queried through this endpoint must - // therefore support off-thread evaluation. - String result = PlaceholderAPI.setPlaceholders(player, expression); - if (!accepting.get() || !plugin.isEnabled()) { - return json(response, 503, "Service Unavailable"); - } - - Lookup lookup = resolveResult(expression, result); - return json(response, Integer.parseInt(lookup.status), lookup.message); - } catch (Exception exception) { - plugin.getLogger().log(Level.WARNING, "Placeholder lookup failed", exception); - return json(response, 500, "Internal Server Error"); - } finally { - concurrent.release(); - } - } - - boolean authorized(String provided) { - if (provided == null || provided.isEmpty()) return false; - byte[] candidate = provided.getBytes(StandardCharsets.UTF_8); - boolean matched = false; - for (String token : config.tokens()) { - matched |= MessageDigest.isEqual(candidate, token.getBytes(StandardCharsets.UTF_8)); - } - return matched; - } - - static int placeholderStatus(String expression, String result) { - return expression.equals(result) ? 406 : 200; - } - - private static Lookup resolveResult(String expression, String result) { - int status = placeholderStatus(expression, result); - return new Lookup(status, status == 406 ? "Invalid Placeholder" : result); - } - - static String json(Response response, int status, String message) { - response.status(status); - response.type("application/json"); - return JSON.toJson(new Lookup(status, message)); - } - - void stop() { - if (!stopped.compareAndSet(false, true)) return; - accepting.set(false); - if (http != null) http.stop(); - } - - void awaitStop() { - if (http != null) http.awaitStop(); - } - - static final class Lookup { - private final String status; - private final String message; - - Lookup(int status, String message) { - this.status = Integer.toString(status); - this.message = message; - } - } -} From 672d01e6a9427161c10875eb8e4524747b7f9003 Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:22:12 +0700 Subject: [PATCH 05/14] refactor: remove obsolete Jetty 9 logger adapter --- .../me/fredthedoggy/restpapi/NoLogging.java | 54 ------------------- 1 file changed, 54 deletions(-) delete mode 100644 src/main/java/me/fredthedoggy/restpapi/NoLogging.java diff --git a/src/main/java/me/fredthedoggy/restpapi/NoLogging.java b/src/main/java/me/fredthedoggy/restpapi/NoLogging.java deleted file mode 100644 index 02c3dd1..0000000 --- a/src/main/java/me/fredthedoggy/restpapi/NoLogging.java +++ /dev/null @@ -1,54 +0,0 @@ -package me.fredthedoggy.restpapi; - -import org.eclipse.jetty.util.log.Logger; - -public class NoLogging implements Logger { - public String getName() { - return "no"; - } - - public void warn(String msg, Object... args) { - } - - public void warn(Throwable thrown) { - } - - public void warn(String msg, Throwable thrown) { - } - - public void info(String msg, Object... args) { - } - - public void info(Throwable thrown) { - } - - public void info(String msg, Throwable thrown) { - } - - public boolean isDebugEnabled() { - return false; - } - - public void setDebugEnabled(boolean enabled) { - } - - public void debug(String msg, Object... args) { - } - - @Override - public void debug(String msg, long value) { - } - - public void debug(Throwable thrown) { - } - - public void debug(String msg, Throwable thrown) { - } - - public Logger getLogger(String name) { - return this; - } - - public void ignore(Throwable ignored) { - } -} From 15208febaa61750df2196b6d1bb3b0ddce4a875d Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:22:33 +0700 Subject: [PATCH 06/14] test: migrate REST tests to Jetty server --- .../restpapi/RestSecurityTest.java | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java b/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java index a444ecf..b01a596 100644 --- a/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java +++ b/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java @@ -3,13 +3,12 @@ import com.google.gson.JsonParser; import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.Test; -import spark.Response; import java.util.Arrays; import java.util.Collections; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; class RestSecurityTest { private YamlConfiguration config() { @@ -20,7 +19,7 @@ private YamlConfiguration config() { @Test void tokensMustMatchExactly() { - SparkWrapper server = new SparkWrapper(mock(Restpapi.class), new RestConfig(config())); + JettyServer server = new JettyServer(mock(Restpapi.class), new RestConfig(config())); assertTrue(server.authorized("12345678-1234-1234-1234-123456789abc")); assertFalse(server.authorized("prefix12345678-1234-1234-1234-123456789abc")); assertFalse(server.authorized("12345678-1234-1234-1234-123456789abc suffix")); @@ -41,14 +40,11 @@ void invalidOrMissingTokensFailClosed() { @Test void serializesPlaceholderTextAndKeepsLegacyStatus() { - Response response = mock(Response.class); String message = "Name: \"Thiago\" \\ test\nline"; - String payload = SparkWrapper.json(response, 200, message); + String payload = JettyServer.jsonPayload(200, message); assertEquals(message, JsonParser.parseString(payload).getAsJsonObject().get("message").getAsString()); assertEquals("200", JsonParser.parseString(payload).getAsJsonObject().get("status").getAsString()); - verify(response).type("application/json"); - verify(response).status(200); - assertEquals("", JsonParser.parseString(SparkWrapper.json(response, 200, "")) + assertEquals("", JsonParser.parseString(JettyServer.jsonPayload(200, "")) .getAsJsonObject().get("message").getAsString()); } @@ -63,9 +59,9 @@ void limitsRequestsPerPeer() { @Test void distinguishesUnknownPlaceholderFromEmptyValue() { - assertEquals(406, SparkWrapper.placeholderStatus("%unknown%", "%unknown%")); - assertEquals(200, SparkWrapper.placeholderStatus("%known%", "")); - assertEquals(200, SparkWrapper.placeholderStatus("%known%", "value")); + assertEquals(406, JettyServer.placeholderStatus("%unknown%", "%unknown%")); + assertEquals(200, JettyServer.placeholderStatus("%known%", "")); + assertEquals(200, JettyServer.placeholderStatus("%known%", "value")); } @Test @@ -80,7 +76,7 @@ void rejectsOutOfRangeNetworkSettings() { @Test void shutdownIsIdempotentBeforeHttpStartup() { - SparkWrapper server = new SparkWrapper(mock(Restpapi.class), new RestConfig(config())); + JettyServer server = new JettyServer(mock(Restpapi.class), new RestConfig(config())); assertDoesNotThrow(server::stop); assertDoesNotThrow(server::stop); } From 661eca46b48c48a2410e76dedb31ed7bddc9f453 Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:23:41 +0700 Subject: [PATCH 07/14] docs: document direct Jetty 12 architecture --- README.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5606f9b..dc27814 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ RestPAPI exposes PlaceholderAPI values from each Bukkit/Paper backend over HTTP. Install PlaceholderAPI and this plugin on **each** backend you want to query. Velocity forwards Minecraft traffic, not these HTTP requests. +## HTTP server + +RestPAPI embeds **Jetty 12.1** directly. Spark Java is not used. + +Jetty keeps its internal server work on its platform-thread pool and dispatches blocking application work through a Java virtual-thread executor. The REST handlers are declared as blocking handlers, so offline-player resolution and PlaceholderAPI evaluation can run on virtual threads without occupying the Minecraft server thread. + +Routing uses Jetty's native `PathMappingsHandler` and `UriTemplatePathSpec`. Request concurrency is enforced by `QoSHandler`, and source-address allowlisting is enforced by `InetAccessHandler`. + ## Configuration On first start, `plugins/RestPAPI/config.yml` is created with two random UUID tokens. Keep them private. Example: @@ -20,11 +28,17 @@ allowed-ips: [] Tokens must contain at least 16 characters, cannot be blank or padded with spaces, and must be unique. A missing or invalid token configuration prevents startup. To rotate tokens, temporarily include old and new tokens, run `/restpapi reload`, update clients, then remove the old token and reload again. Never print the tokens or put them in browser JavaScript. -`bind` selects the interface **inside the container** (default `0.0.0.0`). `allowed-ips` is an exact match list of socket peer IPs; an empty list permits any peer with a valid token. Behind Nginx it will normally see the proxy address, not the original client. It deliberately ignores `X-Forwarded-For`. The rate limit is per socket peer and uses a fixed window; no more than 4096 distinct peers are tracked. The concurrency limit returns HTTP 503 instead of queuing unbounded lookups. +`bind` selects the interface **inside the container** (default `0.0.0.0`). `allowed-ips` is enforced by Jetty against the real remote address of the connection rather than forwarded headers. An empty list permits any peer with a valid token. Values may use Jetty address patterns such as an exact address or CIDR range. Behind Nginx, RestPAPI will normally see the proxy address unless the network topology preserves the original peer address; it deliberately does not trust `X-Forwarded-For` for access control. + +`max-concurrent` is enforced by Jetty's `QoSHandler`. Requests beyond the configured number are rejected immediately with HTTP 503 instead of accumulating an unbounded queue. Jetty's virtual-thread executor has its own resource guard, while `max-concurrent` remains the application-level limit for REST requests. + +The fixed-window rate limit remains implemented by RestPAPI because Jetty's built-in DoS rate limiting uses per-second/leaky-bucket semantics rather than the existing `requests` plus arbitrary `window-seconds` contract. No more than 4096 distinct peers are tracked by the fixed-window limiter. + +Placeholder evaluation, including `Bukkit.getOfflinePlayer(UUID)`, is never scheduled onto the Minecraft main thread. Both player and server placeholder routes execute from Jetty request handling. Third-party PlaceholderAPI expansions queried through this API must therefore support off-thread evaluation; PlaceholderAPI does not make expansion code thread-safe automatically. -Placeholder evaluation, including `Bukkit.getOfflinePlayer(UUID)`, runs directly on Spark's Jetty worker thread and is not scheduled onto the Minecraft main thread. This keeps offline-player lookups and PlaceholderAPI evaluation off the server tick thread, but it also means every expansion queried through this API must support off-thread execution. PlaceholderAPI does not make third-party expansions thread-safe automatically. There is no server-side lookup timeout because a synchronous expansion running on the current Spark worker cannot be safely preempted; use `max-concurrent` to bound concurrent evaluations and configure request timeouts in the HTTP client or reverse proxy. +There is no server-side placeholder timeout because synchronous third-party expansion code cannot be safely preempted. Configure request timeouts in the HTTP client or reverse proxy and use `max-concurrent` to bound simultaneous evaluations. -The command `/restpapi reload` reads the file again, validates it before stopping the old listener, and attempts to restore the old listener if binding the new one fails. A failed rollback disables the plugin. Listener shutdown and startup take place off the Minecraft main thread; the command reports the result after they finish. Requests that begin after shutdown starts receive 503. A successful reload updates both the port and token set. +The command `/restpapi reload` reads the file again, validates it before stopping the old listener, and attempts to restore the old listener if binding the new one fails. Reload lifecycle work runs on a dedicated Java virtual-thread executor. A failed rollback disables the plugin. Requests that begin after shutdown starts receive 503. A successful reload updates the port, bind address, tokens, limits, and allowlist. ## Requests @@ -53,4 +67,4 @@ Then query `https://api.example.com/survival/UUID/player_name` with the `Token` ## Build -Use JDK 25 and `bash gradlew test shadowJar` (Gradle 9.8.0). The plugin targets Paper API 1.21.11; the shaded JAR is written under `build/libs`. +Use JDK 25 and `bash gradlew test shadowJar` (Gradle 9.8.0). The plugin targets Paper API 1.21.11 and embeds Jetty 12.1.13; the shaded JAR is written under `build/libs`. From 7df53d24d563bbf45b08b78ba331caa04aa561d3 Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:24:26 +0700 Subject: [PATCH 08/14] fix: preserve request path for URI template parameters --- src/main/java/me/fredthedoggy/restpapi/JettyServer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/me/fredthedoggy/restpapi/JettyServer.java b/src/main/java/me/fredthedoggy/restpapi/JettyServer.java index ae7ea39..d4e2004 100644 --- a/src/main/java/me/fredthedoggy/restpapi/JettyServer.java +++ b/src/main/java/me/fredthedoggy/restpapi/JettyServer.java @@ -62,7 +62,7 @@ void start() { connector.setPort(config.port()); jetty.addConnector(connector); - PathMappingsHandler routes = new PathMappingsHandler(); + PathMappingsHandler routes = new PathMappingsHandler.NoContext(); routes.addMapping(SERVER_ROUTE, new PlaceholderHandler(SERVER_ROUTE, false)); routes.addMapping(PLAYER_ROUTE, new PlaceholderHandler(PLAYER_ROUTE, true)); From 33a2b9761c912544f6550231d8a1e279de844efe Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:28:16 +0700 Subject: [PATCH 09/14] feat: configure graceful Jetty shutdown timeout --- src/main/java/me/fredthedoggy/restpapi/RestConfig.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/me/fredthedoggy/restpapi/RestConfig.java b/src/main/java/me/fredthedoggy/restpapi/RestConfig.java index 07a429b..9881419 100644 --- a/src/main/java/me/fredthedoggy/restpapi/RestConfig.java +++ b/src/main/java/me/fredthedoggy/restpapi/RestConfig.java @@ -9,7 +9,7 @@ import java.util.Set; final class RestConfig { - private final int port, rateLimit, rateWindowSeconds, maxConcurrent; + private final int port, rateLimit, rateWindowSeconds, maxConcurrent, shutdownTimeoutMillis; private final String bind; private final List tokens; private final Set allowedIps; @@ -20,11 +20,13 @@ final class RestConfig { rateLimit = yaml.getInt("rate-limit.requests", 60); rateWindowSeconds = yaml.getInt("rate-limit.window-seconds", 60); maxConcurrent = yaml.getInt("max-concurrent", 16); + shutdownTimeoutMillis = yaml.getInt("shutdown-timeout-ms", 5000); tokens = Collections.unmodifiableList(new ArrayList<>(yaml.getStringList("tokens"))); allowedIps = Collections.unmodifiableSet(new HashSet<>(yaml.getStringList("allowed-ips"))); if (port < 1 || port > 65535 || bind == null || bind.trim().isEmpty() || rateLimit < 1 || rateLimit > 10000 || rateWindowSeconds < 1 || rateWindowSeconds > 3600 || maxConcurrent < 1 || maxConcurrent > 24 + || shutdownTimeoutMillis < 100 || shutdownTimeoutMillis > 30000 || tokens.isEmpty() || tokens.stream().anyMatch(t -> t == null || t.length() < 16 || !t.equals(t.trim())) || new HashSet<>(tokens).size() != tokens.size() || allowedIps.stream().anyMatch(ip -> ip == null || ip.trim().isEmpty() || !ip.equals(ip.trim()))) { @@ -37,6 +39,7 @@ final class RestConfig { int rateLimit() { return rateLimit; } int rateWindowSeconds() { return rateWindowSeconds; } int maxConcurrent() { return maxConcurrent; } + int shutdownTimeoutMillis() { return shutdownTimeoutMillis; } List tokens() { return tokens; } Set allowedIps() { return allowedIps; } } From 6e7feebdcfe34e72f67dffbbb6d5f7d535c19881 Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:28:24 +0700 Subject: [PATCH 10/14] feat: generate graceful shutdown configuration --- src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java b/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java index 0771028..5c17177 100644 --- a/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java +++ b/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java @@ -30,6 +30,7 @@ void enable() { yaml.set("bind", "0.0.0.0"); yaml.set("tokens", Arrays.asList(UUID.randomUUID().toString(), UUID.randomUUID().toString())); yaml.set("max-concurrent", 16); + yaml.set("shutdown-timeout-ms", 5000); yaml.set("rate-limit.requests", 60); yaml.set("rate-limit.window-seconds", 60); yaml.set("allowed-ips", java.util.Collections.emptyList()); From 114dae2b9cabbb25aabb1693f743b8c0b44b230a Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:29:05 +0700 Subject: [PATCH 11/14] refactor: use Jetty graceful shutdown handling --- .../me/fredthedoggy/restpapi/JettyServer.java | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/main/java/me/fredthedoggy/restpapi/JettyServer.java b/src/main/java/me/fredthedoggy/restpapi/JettyServer.java index d4e2004..51af67b 100644 --- a/src/main/java/me/fredthedoggy/restpapi/JettyServer.java +++ b/src/main/java/me/fredthedoggy/restpapi/JettyServer.java @@ -13,6 +13,7 @@ import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.GracefulHandler; import org.eclipse.jetty.server.handler.InetAccessHandler; import org.eclipse.jetty.server.handler.PathMappingsHandler; import org.eclipse.jetty.server.handler.QoSHandler; @@ -35,7 +36,6 @@ final class JettyServer { private final Restpapi plugin; private final RestConfig config; private final RequestLimiter limiter; - private final AtomicBoolean accepting = new AtomicBoolean(true); private final AtomicBoolean stopped = new AtomicBoolean(false); private Server server; @@ -46,6 +46,7 @@ final class JettyServer { } void start() { + Server jetty = null; try { VirtualThreadPool virtualThreads = new VirtualThreadPool(); virtualThreads.setName("restpapi-virtual"); @@ -54,8 +55,9 @@ void start() { platformThreads.setName("restpapi-jetty"); platformThreads.setVirtualThreadsExecutor(virtualThreads); - Server jetty = new Server(platformThreads); + jetty = new Server(platformThreads); jetty.addBean(virtualThreads); + jetty.setStopTimeout(config.shutdownTimeoutMillis()); ServerConnector connector = new ServerConnector(jetty); connector.setHost(config.bind()); @@ -73,18 +75,24 @@ void start() { qos.setMaxSuspendedRequestCount(0); qos.setRejectStatusCode(HttpStatus.SERVICE_UNAVAILABLE_503); - Handler root = qos; + Handler protectedHandler = qos; if (!config.allowedIps().isEmpty()) { - JsonInetAccessHandler access = new JsonInetAccessHandler(root); + JsonInetAccessHandler access = new JsonInetAccessHandler(protectedHandler); access.include(config.allowedIps().toArray(String[]::new)); - root = access; + protectedHandler = access; } - jetty.setHandler(root); + jetty.setHandler(new JsonGracefulHandler(protectedHandler)); jetty.start(); server = jetty; } catch (Exception exception) { - accepting.set(false); + if (jetty != null) { + try { + jetty.stop(); + } catch (Exception stopException) { + exception.addSuppressed(stopException); + } + } throw new IllegalStateException("Jetty HTTP server could not start", exception); } } @@ -103,7 +111,7 @@ public boolean handle(Request request, Response response, Callback callback) { if (!"GET".equals(request.getMethod())) { return writeJson(response, callback, 404, "Invalid URI"); } - if (!accepting.get() || !plugin.isEnabled()) { + if (!plugin.isEnabled()) { return writeJson(response, callback, 503, "Service Unavailable"); } if (!authorized(request.getHeaders().get("Token"))) { @@ -139,7 +147,7 @@ public boolean handle(Request request, Response response, Callback callback) { String expression = "%" + name + "%"; String result = PlaceholderAPI.setPlaceholders(player, expression); - if (!accepting.get() || !plugin.isEnabled()) { + if (!plugin.isEnabled()) { return writeJson(response, callback, 503, "Service Unavailable"); } @@ -179,7 +187,6 @@ private static boolean writeJson(Response response, Callback callback, int statu void stop() { if (!stopped.compareAndSet(false, true)) return; - accepting.set(false); Server current = server; server = null; if (current == null) return; @@ -191,6 +198,17 @@ void stop() { } } + private static final class JsonGracefulHandler extends GracefulHandler { + private JsonGracefulHandler(Handler handler) { + super(handler); + } + + @Override + protected void handleShutdownRejection(Request request, Response response, Callback callback) { + writeJson(response, callback, 503, "Service Unavailable"); + } + } + private static final class JsonQoSHandler extends QoSHandler { private JsonQoSHandler(Handler handler) { super(handler); From 23cc2a74da0ab092d6920a6aae131c27e6636f1b Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:30:26 +0700 Subject: [PATCH 12/14] docs: document graceful Jetty shutdown --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dc27814..5148db2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ bind: 0.0.0.0 tokens: - "replace-with-a-long-random-secret" max-concurrent: 16 +shutdown-timeout-ms: 5000 rate-limit: requests: 60 window-seconds: 60 @@ -30,7 +31,7 @@ Tokens must contain at least 16 characters, cannot be blank or padded with space `bind` selects the interface **inside the container** (default `0.0.0.0`). `allowed-ips` is enforced by Jetty against the real remote address of the connection rather than forwarded headers. An empty list permits any peer with a valid token. Values may use Jetty address patterns such as an exact address or CIDR range. Behind Nginx, RestPAPI will normally see the proxy address unless the network topology preserves the original peer address; it deliberately does not trust `X-Forwarded-For` for access control. -`max-concurrent` is enforced by Jetty's `QoSHandler`. Requests beyond the configured number are rejected immediately with HTTP 503 instead of accumulating an unbounded queue. Jetty's virtual-thread executor has its own resource guard, while `max-concurrent` remains the application-level limit for REST requests. +`max-concurrent` is enforced by Jetty's `QoSHandler`. Requests beyond the configured number are rejected immediately with HTTP 503 instead of accumulating an unbounded queue. Jetty's virtual-thread executor has its own resource guard, while `max-concurrent` remains the application-level limit for REST requests. `shutdown-timeout-ms` controls how long Jetty waits for in-flight requests to finish during reload or shutdown; new requests are rejected with HTTP 503 once graceful shutdown begins. The fixed-window rate limit remains implemented by RestPAPI because Jetty's built-in DoS rate limiting uses per-second/leaky-bucket semantics rather than the existing `requests` plus arbitrary `window-seconds` contract. No more than 4096 distinct peers are tracked by the fixed-window limiter. @@ -38,7 +39,7 @@ Placeholder evaluation, including `Bukkit.getOfflinePlayer(UUID)`, is never sche There is no server-side placeholder timeout because synchronous third-party expansion code cannot be safely preempted. Configure request timeouts in the HTTP client or reverse proxy and use `max-concurrent` to bound simultaneous evaluations. -The command `/restpapi reload` reads the file again, validates it before stopping the old listener, and attempts to restore the old listener if binding the new one fails. Reload lifecycle work runs on a dedicated Java virtual-thread executor. A failed rollback disables the plugin. Requests that begin after shutdown starts receive 503. A successful reload updates the port, bind address, tokens, limits, and allowlist. +The command `/restpapi reload` reads the file again, validates it before stopping the old listener, and attempts to restore the old listener if binding the new one fails. Reload lifecycle work runs on a dedicated Java virtual-thread executor. Jetty's `GracefulHandler` drains in-flight requests for up to `shutdown-timeout-ms` while rejecting new requests with 503. A failed rollback disables the plugin. A successful reload updates the port, bind address, tokens, limits, graceful-shutdown timeout, and allowlist. ## Requests From c35b579ee49dde4c6df5c69dd8abeba13fa23b62 Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:30:36 +0700 Subject: [PATCH 13/14] test: validate graceful shutdown timeout --- src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java b/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java index b01a596..481b3fd 100644 --- a/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java +++ b/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java @@ -72,6 +72,9 @@ void rejectsOutOfRangeNetworkSettings() { yaml.set("port", 8080); yaml.set("max-concurrent", 1000); assertThrows(IllegalArgumentException.class, () -> new RestConfig(yaml)); + yaml.set("max-concurrent", 16); + yaml.set("shutdown-timeout-ms", 0); + assertThrows(IllegalArgumentException.class, () -> new RestConfig(yaml)); } @Test From b243e860ccaab9f3ca9168552c1e185fe92c0389 Mon Sep 17 00:00:00 2001 From: ThiagoROX <51332006+SrBedrock@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:31:09 +0700 Subject: [PATCH 14/14] chore: remove obsolete Log4j configuration --- src/main/resources/log4j.properties | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 src/main/resources/log4j.properties diff --git a/src/main/resources/log4j.properties b/src/main/resources/log4j.properties deleted file mode 100644 index 9e45be3..0000000 --- a/src/main/resources/log4j.properties +++ /dev/null @@ -1,4 +0,0 @@ -log4j.rootLogger=ERROR, Console, LogFile -log4j.appender.Console=org.apache.log4j.ConsoleAppender -log4j.appender.Console.layout=org.apache.log4j.PatternLayout -log4j.appender.Console.layout.ConversionPattern=%d{ISO8601} [%t] %-5p %C - %m%n \ No newline at end of file