feat: add Adventure messages and asynchronous command completion

This commit is contained in:
layue13 committed 2026-09-27 00:01:02 +08:00
1 parent bbb4b5a831
commit 1185ae2b2e
34 files changed
+1480 -76

No files matched your search

+1
View File
@@ -5,6 +5,7 @@
| 文档 | 内容 |
| --- | --- |
| [架构与边界](architecture.md) | 模块职责、会话状态、线程模型、转服、目录所有权 |
| [消息与命令补全](messages-and-completion.md) | 富文本、模板、异步参数补全及协议边界 |
| [插件开发](plugins.md) | 生命周期、玩家/服务器视图、选服、命令与统一类型化事件 API |
| [玩家操作与部署完善设计](player-operations.md) | 消息、主动断开、服务器列表配置与验收边界 |
| [运行配置](operations.md) | 认证、接入限制、超时、启动和部署边界 |
+62
View File
@@ -0,0 +1,62 @@
# 消息构造与命令补全
## 目标
插件用统一组件表达消息,用简单回调执行命令和补全。代理负责协议 5 编码、连接身份、线程切换和有界资源。消息模板、权限、业务参数与候选过滤由插件决定。
## 富文本
API 导出 Adventure Component 与 MiniMessage。`Players.sendMessage`、`Players.disconnect`、`CommandInvocation.reply`、`AccessDecision.deny` 和 `PlacementDecision.reject` 接受 Component;String 重载表示纯文本,绝不自动解释标签。固定组件可提前构造并复用。
```java
var message = Component.text("返回主城", NamedTextColor.GREEN)
.hoverEvent(Component.text("点击返回主城"))
.clickEvent(ClickEvent.runCommand("/spawn"));
context.players().sendMessage(player.identity(), message);
var greeting = MiniMessage.miniMessage().deserialize(
"<green>欢迎 <player>!</green>",
Placeholder.unparsed("player", player.username()));
invocation.reply(greeting);
```
动态字符串通过 `Placeholder.unparsed` 插入;组件通过 `Placeholder.component` 插入。插件显式解析模板,解析与组件构造放在插件工作线程。普通后端消息仍沿用转发路径。
1.7.10 支持文字、翻译组件、16 色与文字样式、打开网址、执行命令、填入命令和悬浮文字。RGB 映射到最近的传统颜色;不支持的组件、样式或事件明确拒绝。当前不提供物品/实体悬浮适配。LOGIN/PLAY 使用相同 JSON 编码;踢出界面不保证聊天窗口的交互能力。
树深度最多 32、节点最多 256,编码后的 JSON 最多 32,767 UTF-8 字节;悬浮文本和翻译参数也计入树限制。纯文本快捷入口最多 1,024 个 Unicode 码点。`Players` 在调用线程验证、编码,再将编码结果提交玩家 EventLoop;不在消息写入时重复解析模板。既有背压、最多 64 条未完成消息和连接代次检查继续生效。
## 插件命令
```java
context.commands().register("server", invocation -> {
invocation.reply(Component.text("当前后端:")
.append(Component.text(invocation.player().currentServer().orElse("未连接"),
NamedTextColor.YELLOW)));
}, completion -> {
String args = completion.arguments();
String prefix = args.substring(args.lastIndexOf(' ') + 1);
return CompletableFuture.completedFuture(context.servers().all().stream()
.map(ServerView::name)
.filter(name -> name.startsWith(prefix))
.toList());
});
```
两参数 `register(name, handler)` 仍适合无需参数补全的命令。三参数版本增加 `CommandCompleter`,返回 `CompletionStage<List<String>>`,可接数据库或其他异步查询。`CommandCompletion` 提供玩家快照、无斜杠的根命令名和根命令后去掉一个分隔字符的参数原文;末尾空格保留。`/server ` 会以空参数调用补全器。
候选是客户端用于替换最后一个词的字符串;前缀过滤由插件处理。最多返回 100 项,去重,忽略空值、空字符串、含空白或超过 100 个 UTF-16 单元的候选,并限制总字节数。插件负责命令执行的权限检查;补全器应只返回允许当前玩家看到的候选。根命令名本身公开可补全,不提供隐式权限系统。
## 线程与协议归属
- 插件执行与补全回调在有界命令工作池运行,补全等待最多 1 秒,全宿主最多 128 个未决请求。异常、超时、卸载或过载得到空候选;关闭注册或玩家连接会尽力取消异步任务。
- `/prefix` 请求转给后端,返回时加入匹配的代理根命令并去重。已注册代理命令的参数只由代理插件补全,即使没有补全器也不泄漏到后端。未知命令和普通玩家名补全转给后端;正常返回包保持原始字节。
- 1.7.10 没有补全请求 ID。每连接同时处理一个请求,额外排队最多 8 个;超出队列限制关闭连接,避免无限积压。按请求顺序回答,协议本身无法识别客户端已经编辑了哪一个输入框状态。
- 后端 1 秒未回复时,根补全先返回代理候选,其他请求返回空候选。保留一个迟到回复标记;在收到并丢弃该回复前,不再向该后端发补全请求,以免把旧回复归给新请求。插件补全仍可用。转服切换后使用新的状态。
- 切换后端、断线和主动踢出关闭旧补全状态,释放暂存帧并取消插件等待。普通 PLAY 帧只检查包 ID,不解析、复制或派发补全回调。
## 验收
组件与协议测试覆盖颜色、点击、悬浮、翻译参数、转义、尺寸/深度限制和不支持的能力。补全测试覆盖根合并、参数末尾空格、后端透传、异步完成、超时、迟到回复隔离及关闭清理;真实 TCP 测试覆盖发行路径使用的会话和插件宿主连线。构建与测试结果记录在工作交付中,客户端视觉交互单独标明是否完成实测。
本机额外用 Java 8 加载 Uranium 实际组件解析器,读取当前发行包生成的富文本 JSON;正文、点击命令 /spawn 和悬浮正文均匹配。所用 Uranium JAR SHA-256 为 B16747D08BAD4B7C67DB8F1F41C9C31066FEAAAC22B527775F604BDFA885BAD2。该检查不启动客户端,不证明鼠标交互或渲染效果已实测。
+1 -1
View File
@@ -1,6 +1,6 @@
# 玩家操作与部署完善设计
状态:首批实现与本地自动化验收已完成。设计基线为 `ea78523`;真实在线认证和目标整合包验收仍待完成。
状态:本文保留首批玩家操作的设计基线。当前消息模型见[富文本与命令补全](messages-and-completion.md),真实在线认证与当前整合包结果见[验收记录](../benchmarks/results/2026-09-26-prism-pack.md)。
## 目标与边界
+4 -2
View File
@@ -23,8 +23,8 @@
`Players.sendMessage(identity, text)` 和 `disconnect(identity, reason)` 均绑定完整的 `PlayerIdentity`,可从任意线程调用;通过插件上下文获取的 stage 在 I/O 线程外完成。参数错误立即报告,插件停用后拒绝继续提交。
- 消息是纯文本,最多 1024 个 Unicode 码点。返回 `MessageResult.SENT` 表示网络写入完成;连接消失或正在关闭返回 `NOT_CONNECTED`;登录、初次 Forge 握手未就绪或转服交接期间返回 `NOT_READY`;不可写或该连接已有 64 条未完成消息返回 `BACKPRESSURED`。网络写失败以异常完成。
- 主动断开要求非空白原因,最多 1024 个 Unicode 码点。核心按实际 LOGIN/PLAY 阶段发包,最长等待 5 秒后清理连接、未决转服和候选后端。返回 `DisconnectResult.DISCONNECTED` 表示连接清理完成,原因文本仅尽力交付;身份已失效返回 `NOT_CONNECTED`。重复断开合并处理,取消调用方等待不撤销已提交的断开。
- 消息支持 Adventure Component;String 入口为纯文本,最多 1024 个 Unicode 码点。返回 `MessageResult.SENT` 表示网络写入完成;连接消失或正在关闭返回 `NOT_CONNECTED`;登录、初次 Forge 握手未就绪或转服交接期间返回 `NOT_READY`;不可写或该连接已有 64 条未完成消息返回 `BACKPRESSURED`。网络写失败以异常完成。
- 主动断开接受非空白的 Component 原因;String 入口最多 1024 个 Unicode 码点。核心按实际 LOGIN/PLAY 阶段发包,最长等待 5 秒后清理连接、未决转服和候选后端。返回 `DisconnectResult.DISCONNECTED` 表示连接清理完成,原因文本仅尽力交付;身份已失效返回 `NOT_CONNECTED`。重复断开合并处理,取消调用方等待不撤销已提交的断开。
- `disconnect` 也支持准入或初始选服回调中的已识别玩家,此时玩家还未出现在 `online()` 中。
```java
@@ -128,3 +128,5 @@ context.events().subscribe(PlayerDisconnectedEvent.class, event ->
统一事件模型让准入决策和会话通知共享同一类型、监听器、订阅表与派发实现,同时保留各类事件所需的运行策略。准入阶段执行有序且失败关闭的结果聚合;生命周期通知异步 FIFO 尽力交付并与会话处理隔离。没有 PLAY 包事件。目前没有事件 API 的专门基准,不能据此宣称性能收益或无开销。
源码入口:[`PluginContext`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/PluginContext.java)、[`Events`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/event/Events.java)、[`AccessDecision`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/AccessDecision.java)。
富文本构造、MiniMessage 和异步命令补全示例见[消息与命令补全](messages-and-completion.md)。
+8
View File
@@ -102,3 +102,11 @@ PR 的 `.gitea/workflows/verify.yml` 独立运行 `gradlew check`,不使用 Ma
将 `-Window` 改为 `16` 可测每连接最多 16 个在途往返的情形。
原 stop-and-wait 小样本见 `benchmarks/results/proxy-session-benchmark-smoke-2026-09-25.md`;Window 参数的小样本和 Window=1/16 重复测量见 `benchmarks/results/2026-09-25-proxy-session-window.md`。这些是环回网络上的合成帧对照,不能代表 Forge 整合包、真实后端或跨主机部署的性能。
## 富文本与命令补全验证(2026-09-27)
实现设计见[消息与命令补全](messages-and-completion.md)。本地 `gradlew check` 通过:217 项测试(API 7、核心 197、DNS 6、Agent 7),失败、错误、跳过均为 0;发行包帮助、版本、配置校验通过。
新增验证包含组件构造与旧版 JSON 字段、模板变量作为文字插入、消息深度/节点/字节限制;根命令与后端候选合并、参数补全、原始后端回复透传;超时、迟到回复隔离、队列上限、转服代次切换、插件注销/关闭与取消回调线程。真实 TCP 测试同时核对富文本命令回复和补全,转服测试核对切换前后的补全上下文。
此外用 Java 8 加载本机 Uranium 的实际组件解析器读取当前发行包生成的 JSON,核对正文、点击命令和悬浮文字通过。未启动真实客户端进行视觉/鼠标交互验收;没有测量本次改动的吞吐收益。最终远端 CI 按对应 PR 检查项记录。
+5
View File
@@ -4,6 +4,7 @@ netty = "4.2.2.Final"
jackson = "2.20.0"
slf4j = "2.0.18"
logback = "1.6.3"
adventure = "5.2.0"
[libraries]
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
@@ -16,3 +17,7 @@ jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", ver
jackson-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
adventure-api = { module = "net.kyori:adventure-api", version.ref = "adventure" }
adventure-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure" }
adventure-gson = { module = "net.kyori:adventure-text-serializer-gson", version.ref = "adventure" }
adventure-plain = { module = "net.kyori:adventure-text-serializer-plain", version.ref = "adventure" }
@@ -202,10 +202,10 @@ class AgentDiscoveryPluginTest {
return new Players() {
@Override public Optional<PlayerView> find(PlayerIdentity identity) { return Optional.empty(); }
@Override public List<PlayerView> online() { return List.of(); }
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.api.MessageResult> sendMessage(PlayerIdentity identity, String message) {
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.api.MessageResult> sendMessage(PlayerIdentity identity, net.kyori.adventure.text.Component message) {
return CompletableFuture.failedFuture(new UnsupportedOperationException());
}
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.api.DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.api.DisconnectResult> disconnect(PlayerIdentity identity, net.kyori.adventure.text.Component reason) {
return CompletableFuture.failedFuture(new UnsupportedOperationException());
}
@Override public java.util.concurrent.CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName) {
@@ -215,7 +215,7 @@ class AgentDiscoveryPluginTest {
}
@Override public Servers servers() { return servers; }
@Override public dev.strataproxy.api.Commands commands() {
return (name, handler) -> { throw new UnsupportedOperationException(); };
return (name, handler, completer) -> { throw new UnsupportedOperationException(); };
}
@Override public dev.strataproxy.api.event.Events events() { throw new UnsupportedOperationException(); }
@Override public org.slf4j.Logger logger() { return org.slf4j.LoggerFactory.getLogger("agent-test"); }
@@ -306,7 +306,7 @@ class DnsDiscoveryPluginTest {
}
@Override public dev.strataproxy.api.Commands commands() {
return (name, handler) -> { throw new UnsupportedOperationException(); };
return (name, handler, completer) -> { throw new UnsupportedOperationException(); };
}
@Override public dev.strataproxy.api.event.Events events() { throw new UnsupportedOperationException(); }
+2
View File
@@ -17,6 +17,8 @@ dependencies {
implementation(libs.findLibrary("netty-resolver-dns").get())
implementation(libs.findLibrary("jackson-databind").get())
implementation(libs.findLibrary("jackson-yaml").get())
implementation(libs.findLibrary("adventure-gson").get())
implementation(libs.findLibrary("adventure-plain").get())
runtimeOnly(libs.findLibrary("logback-classic").get())
}
@@ -89,6 +89,7 @@ public final class ProxyMain {
listener.setEvents(plugins, eventTimeout);
listener.setPlacement(plugins::placeInitial);
listener.setCommandDispatcher(plugins::dispatchCommand);
listener.setCommandCompletion(plugins::commandNames, plugins::completeCommand);
var serverChannel = listener.start().toCompletableFuture().join();
LOGGER.info("Listening on {}", serverChannel.localAddress());
var shutdown = new Thread(() -> {
@@ -13,6 +13,8 @@ import dev.strataproxy.api.event.PlayerDisconnectedEvent;
import dev.strataproxy.api.event.ServerConnectedEvent;
import dev.strataproxy.api.InitialPlacementHandler;
import dev.strataproxy.api.CommandHandler;
import dev.strataproxy.api.CommandCompleter;
import dev.strataproxy.api.CommandCompletion;
import dev.strataproxy.api.CommandInvocation;
import dev.strataproxy.api.CommandRegistration;
import dev.strataproxy.api.Commands;
@@ -27,6 +29,7 @@ import dev.strataproxy.api.ServerRegistration;
import dev.strataproxy.api.ServerView;
import dev.strataproxy.api.Servers;
import dev.strataproxy.api.TransferResult;
import net.kyori.adventure.text.Component;
import dev.strataproxy.core.backend.BackendCatalog;
import dev.strataproxy.core.backend.BackendId;
import dev.strataproxy.core.backend.BackendOwner;
@@ -71,6 +74,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
@@ -80,6 +84,12 @@ import java.util.jar.JarFile;
public final class PluginHost implements AutoCloseable, EventDispatcher {
private static final Logger LOGGER = LoggerFactory.getLogger(PluginHost.class);
private static final AtomicLong NEXT_HOST_GENERATION = new AtomicLong();
private static final int MAX_PENDING_COMMAND_COMPLETIONS = 128;
private static final int MAX_COMPLETION_RESULTS = 100;
// Leave room for bounded protocol encoding overhead beyond suggestion UTF-8 bodies.
private static final int MAX_COMPLETION_BYTES = 32_000;
private static final int MAX_COMPLETION_CANDIDATES_SCANNED = 4096;
private static final Duration COMMAND_COMPLETION_TIMEOUT = Duration.ofSeconds(1);
private static final ThreadFactory PLAYER_COMPLETION_THREADS = Thread.ofVirtual()
.name("strataproxy-plugin-player-", 0).factory();
private static final Executor PLAYER_COMPLETIONS = task -> PLAYER_COMPLETION_THREADS.newThread(task).start();
@@ -90,6 +100,8 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
private final ThreadPoolExecutor callbacks;
private final ThreadPoolExecutor commandWorkers;
private final ConcurrentHashMap<String, RegisteredCommand> commands = new ConcurrentHashMap<>();
private final Set<PendingCompletion> pendingCompletions = ConcurrentHashMap.newKeySet();
private final AtomicInteger pendingCompletionCount = new AtomicInteger();
private final ScheduledThreadPoolExecutor timer;
private final AsyncEventDispatcher admissionEvents;
private final AsyncEventDispatcher notificationEvents;
@@ -422,13 +434,168 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
return result;
}
/** Returns up to 100 registered roots matching a case-insensitive prefix without a leading slash. */
public List<String> commandNames(String prefix) {
Objects.requireNonNull(prefix, "prefix");
if (state != State.ENABLED) return List.of();
String normalized = prefix.toLowerCase(Locale.ROOT);
return commands.values().stream()
.filter(command -> command.context.active && command.name.startsWith(normalized))
.map(command -> "/" + command.name)
.sorted()
.limit(MAX_COMPLETION_RESULTS)
.toList();
}
/**
* Completes arguments for a known slash command. Root-only input belongs to {@link #commandNames(String)};
* the callback receives arguments after exactly one root separator, preserving all remaining whitespace.
*/
public Optional<CompletionStage<List<String>>> completeCommand(PlayerView player, String input) {
Objects.requireNonNull(player, "player");
Objects.requireNonNull(input, "input");
if (state != State.ENABLED || input.length() < 2 || input.charAt(0) != '/') return Optional.empty();
int end = 1;
while (end < input.length() && !Character.isWhitespace(input.charAt(end))) end++;
if (end == input.length()) return Optional.empty();
String name = input.substring(1, end).toLowerCase(Locale.ROOT);
RegisteredCommand registered = commands.get(name);
if (registered == null || !registered.context.active) return Optional.empty();
String arguments = input.substring(end + 1);
if (registered.completer == null) {
return Optional.of(CompletableFuture.completedFuture(List.of()));
}
synchronized (this) {
if (state != State.ENABLED || commands.get(name) != registered || !registered.context.active) {
return Optional.empty();
}
if (!reserveCompletionSlot()) {
return Optional.of(CompletableFuture.failedFuture(new PluginOverloadedException(
new RejectedExecutionException("Too many pending command completions"))));
}
PendingCompletion pending = new PendingCompletion(registered, player, name, arguments);
pendingCompletions.add(pending);
registered.pendingCompletions.add(pending);
pending.result.whenComplete((ignored, failure) -> {
if (pending.result.isCancelled()) finishCompletion(pending, null,
new java.util.concurrent.CancellationException("Command completion cancelled"), true);
});
try {
pending.timeout = timer.schedule(() -> finishCompletion(pending, null,
new TimeoutException("Plugin command completion timed out"), true),
COMMAND_COMPLETION_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS);
FutureTask<Void> invocation = new FutureTask<>(() -> {
invokeCompleter(pending);
return null;
});
pending.invocation = invocation;
commandWorkers.execute(invocation);
if (pending.finished.get()) {
invocation.cancel(true);
commandWorkers.remove(invocation);
}
} catch (RejectedExecutionException overloaded) {
finishCompletion(pending, null, new PluginOverloadedException(overloaded), true);
} catch (RuntimeException failure) {
finishCompletion(pending, null, failure, true);
}
return Optional.of(pending.result);
}
}
private boolean reserveCompletionSlot() {
while (true) {
int current = pendingCompletionCount.get();
if (current >= MAX_PENDING_COMMAND_COMPLETIONS) return false;
if (pendingCompletionCount.compareAndSet(current, current + 1)) return true;
}
}
private void invokeCompleter(PendingCompletion pending) {
if (pending.finished.get()) return;
if (state != State.ENABLED || !pending.registered.context.active
|| commands.get(pending.commandName) != pending.registered) {
finishCompletion(pending, null, new IllegalStateException("Command is no longer registered"), true);
return;
}
try {
CompletionStage<List<String>> stage = Objects.requireNonNull(
pending.registered.completer.complete(new CommandCompletion(
pending.player, pending.commandName, pending.arguments)),
"Command completer returned a null stage");
CompletableFuture<List<String>> cancellable = stage.toCompletableFuture();
pending.pluginStage.set(cancellable);
if (pending.finished.get()) cancellable.cancel(true);
stage.whenCompleteAsync((suggestions, failure) -> {
if (failure != null) finishCompletion(pending, null, failure, false);
else {
try {
finishCompletion(pending, sanitizeSuggestions(
Objects.requireNonNull(suggestions, "Command completer returned null suggestions")),
null, false);
} catch (Throwable invalid) {
finishCompletion(pending, null, invalid, false);
}
}
}, commandWorkers);
} catch (Throwable failure) {
finishCompletion(pending, null, failure, true);
}
}
private static List<String> sanitizeSuggestions(List<String> suggestions) {
var result = new ArrayList<String>(Math.min(suggestions.size(), MAX_COMPLETION_RESULTS));
var unique = new HashSet<String>();
int utf8Bytes = 0;
int scanned = 0;
for (String suggestion : suggestions) {
if (scanned++ >= MAX_COMPLETION_CANDIDATES_SCANNED || result.size() >= MAX_COMPLETION_RESULTS) break;
if (suggestion == null || suggestion.isEmpty() || suggestion.length() > 100 || containsWhitespace(suggestion)
|| !unique.add(suggestion)) continue;
int bytes = suggestion.getBytes(StandardCharsets.UTF_8).length;
if (utf8Bytes + bytes > MAX_COMPLETION_BYTES) continue;
result.add(suggestion);
utf8Bytes += bytes;
}
return List.copyOf(result);
}
private static boolean containsWhitespace(String value) {
for (int offset = 0; offset < value.length();) {
int codePoint = value.codePointAt(offset);
if (Character.isWhitespace(codePoint) || Character.isSpaceChar(codePoint)) return true;
offset += Character.charCount(codePoint);
}
return false;
}
private void finishCompletion(PendingCompletion pending, List<String> suggestions, Throwable failure,
boolean cancelExecution) {
if (!pending.finished.compareAndSet(false, true)) return;
pendingCompletions.remove(pending);
pending.registered.pendingCompletions.remove(pending);
pendingCompletionCount.decrementAndGet();
if (pending.timeout != null) pending.timeout.cancel(false);
FutureTask<Void> invocation = pending.invocation;
if (invocation != null && cancelExecution && !invocation.isDone()) {
commandWorkers.remove(invocation);
invocation.cancel(true);
}
CompletableFuture<List<String>> pluginStage = pending.pluginStage.get();
if (pluginStage != null && cancelExecution && !pluginStage.isDone()) {
PLAYER_COMPLETIONS.execute(() -> pluginStage.cancel(true));
}
if (failure != null) pending.result.completeExceptionally(failure);
else pending.result.complete(Objects.requireNonNull(suggestions, "suggestions"));
}
/** Claims only known root commands; caller retains and forwards every other chat frame. */
public boolean dispatchCommand(PlayerView player, String message, Consumer<String> reply) {
public boolean dispatchCommand(PlayerView player, String message, Consumer<Component> reply) {
return dispatchCommand(player, message, reply, () -> true);
}
/** Admission is checked only after the command name is known, preserving unknown-command passthrough. */
public boolean dispatchCommand(PlayerView player, String message, Consumer<String> reply,
public boolean dispatchCommand(PlayerView player, String message, Consumer<Component> reply,
BooleanSupplier admission) {
Objects.requireNonNull(player, "player");
Objects.requireNonNull(message, "message");
@@ -441,9 +608,9 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
RegisteredCommand registered = commands.get(name);
if (registered == null || !registered.context.active) return false;
if (!admission.getAsBoolean()) return true;
String arguments = end == message.length() ? "" : message.substring(end).stripLeading();
String arguments = end == message.length() ? "" : message.substring(end + 1);
CommandInvocation invocation = new CommandInvocation(player, name, arguments,
text -> { if (registered.context.active) reply.accept(text); });
component -> { if (registered.context.active) reply.accept(component); });
try {
commandWorkers.execute(() -> {
if (state != State.ENABLED || !registered.context.active || commands.get(name) != registered) return;
@@ -455,7 +622,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
}
});
} catch (RejectedExecutionException overloaded) {
reply.accept("Proxy command service is busy. Please try again.");
reply.accept(Component.text("Proxy command service is busy. Please try again."));
}
return true;
}
@@ -477,6 +644,9 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
request.decided().set(true);
request.result().completeExceptionally(new IllegalStateException("Plugin host closed"));
}
for (PendingCompletion pending : pendingCompletions) {
finishCompletion(pending, null, new IllegalStateException("Plugin host closed"), true);
}
callbacks.shutdownNow();
commandWorkers.shutdownNow();
timer.shutdownNow();
@@ -618,7 +788,40 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
private record PendingPlacement(CompletableFuture<Optional<PlacementDecision>> result,
AtomicBoolean decided) { }
private record RegisteredCommand(String name, PluginContextImpl context, CommandHandler handler) { }
private final class RegisteredCommand {
private final String name;
private final PluginContextImpl context;
private final CommandHandler handler;
private final CommandCompleter completer;
private final Set<PendingCompletion> pendingCompletions = ConcurrentHashMap.newKeySet();
private RegisteredCommand(String name, PluginContextImpl context, CommandHandler handler,
CommandCompleter completer) {
this.name = name;
this.context = context;
this.handler = handler;
this.completer = completer;
}
}
private final class PendingCompletion {
private final RegisteredCommand registered;
private final PlayerView player;
private final String commandName;
private final String arguments;
private final CompletableFuture<List<String>> result = new CompletableFuture<>();
private final AtomicBoolean finished = new AtomicBoolean();
private final AtomicReference<CompletableFuture<List<String>>> pluginStage = new AtomicReference<>();
private volatile FutureTask<Void> invocation;
private volatile ScheduledFuture<?> timeout;
private PendingCompletion(RegisteredCommand registered, PlayerView player, String commandName, String arguments) {
this.registered = registered;
this.player = player;
this.commandName = commandName;
this.arguments = arguments;
}
}
private final class EventRegistration<E extends Event<R>, R>
implements EventSubscription, AsyncEventDispatcher.EventHandler<R> {
@@ -689,6 +892,13 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
active = false;
eventRegistrationOpen = false;
revokeEventSubscriptions();
for (RegisteredCommand command : commands.values()) {
if (command.context == this) {
for (PendingCompletion pending : command.pendingCompletions) {
finishCompletion(pending, null, new IllegalStateException("Plugin unloaded"), true);
}
}
}
commands.entrySet().removeIf(entry -> entry.getValue().context == this);
catalog.removeOwner(owner);
}
@@ -734,7 +944,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
private PluginCommands(PluginContextImpl context) { this.context = context; }
@Override public CommandRegistration register(String name, CommandHandler handler) {
@Override public CommandRegistration register(String name, CommandHandler handler, CommandCompleter completer) {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(handler, "handler");
String normalized = name.toLowerCase(Locale.ROOT);
@@ -743,11 +953,19 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
}
synchronized (context) {
context.requireActive();
RegisteredCommand registration = new RegisteredCommand(normalized, context, handler);
RegisteredCommand registration = new RegisteredCommand(normalized, context, handler, completer);
if (commands.putIfAbsent(normalized, registration) != null) {
throw new IllegalArgumentException("Command already registered: /" + normalized);
}
return () -> commands.remove(normalized, registration);
return () -> {
synchronized (PluginHost.this) {
if (commands.remove(normalized, registration)) {
for (PendingCompletion pending : registration.pendingCompletions) {
finishCompletion(pending, null, new IllegalStateException("Command unregistered"), true);
}
}
}
};
}
}
}
@@ -783,7 +1001,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
}
}
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message) {
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, Component message) {
synchronized (context) {
context.requireActive();
return players.sendMessage(identity, message)
@@ -791,7 +1009,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
}
}
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, Component reason) {
synchronized (context) {
context.requireActive();
return players.disconnect(identity, reason)
@@ -1,15 +1,14 @@
package dev.strataproxy.core.protocol;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import net.kyori.adventure.text.Component;
import java.util.Optional;
import java.util.OptionalInt;
/** The few protocol 5 PLAY packets a backend switch must understand or synthesize. */
public final class Minecraft1710PlayPackets {
private static final ObjectMapper JSON = new ObjectMapper();
public static final int CLIENT_CHAT = 0x01;
public static final int SERVER_CHAT = 0x02;
public static final int JOIN_GAME = 0x01;
@@ -44,11 +43,17 @@ public final class Minecraft1710PlayPackets {
/** Encodes a plain-text proxy reply as a protocol 5 clientbound PLAY chat frame. */
public static ByteBuf chatReply(ByteBufAllocator allocator, String message) {
validateText(message, true);
String component = JSON.createObjectNode().put("text", message).toString();
return chatReplyEncoded(allocator, MinecraftText.encode(Component.text(message)));
}
/** Encodes a protocol 5 clientbound PLAY chat frame from prevalidated component JSON. */
public static ByteBuf chatReplyEncoded(ByteBufAllocator allocator, String componentJson) {
MinecraftText.validateEncoded(componentJson);
if (allocator == null) throw new NullPointerException("allocator");
ByteBuf packet = allocator.buffer();
try {
ProtocolVarInt.write(packet, SERVER_CHAT);
ProtocolStrings.write(packet, component, 32767);
ProtocolStrings.write(packet, componentJson, 32767);
return frame(allocator, packet);
} finally {
packet.release();
@@ -58,11 +63,17 @@ public final class Minecraft1710PlayPackets {
/** Encodes an unframed protocol 5 PLAY Disconnect payload. */
public static ByteBuf disconnect(ByteBufAllocator allocator, String reason) {
validateText(reason, false);
String component = JSON.createObjectNode().put("text", reason).toString();
return disconnectEncoded(allocator, MinecraftText.encodeReason(Component.text(reason)));
}
/** Encodes an unframed protocol 5 PLAY Disconnect payload from prevalidated component JSON. */
public static ByteBuf disconnectEncoded(ByteBufAllocator allocator, String componentJson) {
MinecraftText.validateEncoded(componentJson);
if (allocator == null) throw new NullPointerException("allocator");
ByteBuf packet = allocator.buffer();
try {
ProtocolVarInt.write(packet, SERVER_DISCONNECT);
ProtocolStrings.write(packet, component, 32767);
ProtocolStrings.write(packet, componentJson, 32767);
return packet;
} catch (RuntimeException failure) {
packet.release();
@@ -1,14 +1,13 @@
package dev.strataproxy.core.protocol;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import net.kyori.adventure.text.Component;
import java.util.Objects;
/** Protocol 5 LOGIN clientbound Disconnect (packet id 0x00). */
public final class MinecraftLoginDisconnect {
private static final ObjectMapper JSON = new ObjectMapper();
private MinecraftLoginDisconnect() { }
@@ -18,11 +17,17 @@ public final class MinecraftLoginDisconnect {
if (reason.isBlank() || reason.codePointCount(0, reason.length()) > 1024) {
throw new IllegalArgumentException("reason must contain 1 to 1024 characters");
}
String component = JSON.createObjectNode().put("text", reason).toString();
return encodeJson(allocator, MinecraftText.encodeReason(Component.text(reason)));
}
/** Encodes a LOGIN Disconnect payload from prevalidated component JSON. */
public static ByteBuf encodeJson(ByteBufAllocator allocator, String componentJson) {
Objects.requireNonNull(allocator, "allocator");
MinecraftText.validateEncoded(componentJson);
ByteBuf packet = allocator.buffer();
try {
ProtocolVarInt.write(packet, 0);
ProtocolStrings.write(packet, component, 32767);
ProtocolStrings.write(packet, componentJson, 32767);
return packet;
} catch (RuntimeException failure) {
packet.release();
@@ -0,0 +1,63 @@
package dev.strataproxy.core.protocol;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import java.util.ArrayList;
import java.util.List;
/** Protocol 5 completion packets contain no request identifier. */
public final class MinecraftTabCompletion {
public static final int REQUEST = 0x14;
public static final int RESPONSE = 0x3a;
private MinecraftTabCompletion() { }
public static boolean matches(ByteBuf frame, int packetId) {
int offset = frame.readerIndex();
for (int i = 0; i < 3 && offset < frame.writerIndex(); i++) {
if ((frame.getUnsignedByte(offset++) & 0x80) == 0) {
return offset < frame.writerIndex() && frame.getUnsignedByte(offset) == packetId;
}
}
return false;
}
private static ByteBuf body(ByteBuf frame, int id) {
ByteBuf input = frame.duplicate();
if (ProtocolVarInt.read(input) != input.readableBytes() || ProtocolVarInt.read(input) != id) {
throw new ProtocolException("invalid completion packet");
}
return input;
}
public static String request(ByteBuf frame) {
ByteBuf input = body(frame, REQUEST);
String text = ProtocolStrings.read(input, 32767);
if (input.isReadable()) throw new ProtocolException("trailing completion request bytes");
return text;
}
public static List<String> response(ByteBuf frame) {
ByteBuf input = body(frame, RESPONSE);
int count = ProtocolVarInt.read(input);
if (count < 0 || count > 4096) throw new ProtocolException("too many backend completions");
List<String> result = new ArrayList<>(Math.min(count, 100));
for (int i = 0; i < count; i++) {
String text = ProtocolStrings.read(input, 32767);
if (result.size() < 100 && text.length() <= 100) result.add(text);
}
if (input.isReadable()) throw new ProtocolException("trailing completion response bytes");
return result;
}
public static ByteBuf response(ByteBufAllocator allocator, List<String> suggestions) {
ByteBuf body = allocator.buffer();
try {
if (suggestions.size() > 100) throw new IllegalArgumentException("too many completions");
ProtocolVarInt.write(body, RESPONSE);
ProtocolVarInt.write(body, suggestions.size());
for (String suggestion : suggestions) ProtocolStrings.write(body, suggestion, 100);
if (body.readableBytes() > 32767) throw new IllegalArgumentException("completion response too large");
return Minecraft1710PlayPackets.frame(allocator, body);
} finally { body.release(); }
}
}
@@ -0,0 +1,131 @@
package dev.strataproxy.core.protocol;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.ComponentLike;
import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.TranslatableComponent;
import net.kyori.adventure.text.TranslationArgument;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/** Validates and serializes Adventure components for Minecraft 1.7.10 JSON chat. */
public final class MinecraftText {
public static final int MAX_JSON_BYTES = 32_767;
private static final int MAX_DEPTH = 32;
private static final int MAX_NODES = 256;
private static final GsonComponentSerializer SERIALIZER = GsonComponentSerializer.colorDownsamplingGson().toBuilder()
.editOptions(options -> options.value(
net.kyori.adventure.text.serializer.json.JSONOptions.EMIT_COMPACT_TEXT_COMPONENT, false))
.build();
private MinecraftText() { }
/** Serializes a supported component tree, failing before a caller schedules a network write. */
public static String encode(Component component) {
Objects.requireNonNull(component, "component");
validateAdventureNodes(component, 1, new ValidationState());
return encodeValidated(component);
}
/** Serializes a disconnect component and requires at least one non-whitespace visible character. */
public static String encodeReason(Component component) {
Objects.requireNonNull(component, "component");
validateAdventureNodes(component, 1, new ValidationState());
String plain = PlainTextComponentSerializer.plainText().serialize(component);
if (plain.isBlank()) throw new IllegalArgumentException("disconnect reason must not be blank");
return encodeValidated(component);
}
/** Applies the shared wire-size bound to a pre-encoded component before packet allocation. */
public static void validateEncoded(String encoded) {
Objects.requireNonNull(encoded, "encoded");
if (encoded.length() > MAX_JSON_BYTES) {
throw new IllegalArgumentException("encoded component exceeds " + MAX_JSON_BYTES + " bytes");
}
int bytes = encoded.getBytes(StandardCharsets.UTF_8).length;
if (bytes > MAX_JSON_BYTES) {
throw new IllegalArgumentException("encoded component exceeds " + MAX_JSON_BYTES + " bytes");
}
}
private static String encodeValidated(Component component) {
String encoded = SERIALIZER.serialize(component);
validateEncoded(encoded);
return encoded;
}
private static void validateAdventureNodes(Component component, int depth, ValidationState state) {
if (depth > MAX_DEPTH || ++state.count > MAX_NODES) {
throw new IllegalArgumentException("component tree exceeds supported depth or node count");
}
if (!(component instanceof TextComponent) && !(component instanceof TranslatableComponent)) {
throw new IllegalArgumentException("Minecraft 1.7.10 supports only text and translatable components");
}
if (component instanceof TextComponent text) {
state.addText(text.content());
} else if (component instanceof TranslatableComponent translatable) {
state.addText(translatable.key());
if (translatable.fallback() != null) {
throw new IllegalArgumentException("translatable fallback text is unsupported by Minecraft 1.7.10");
}
for (TranslationArgument argument : translatable.arguments()) {
Object value = argument.value();
if (value instanceof ComponentLike child) validateAdventureNodes(child.asComponent(), depth + 1, state);
else if (++state.count > MAX_NODES) {
throw new IllegalArgumentException("too many translation arguments");
}
else if (value instanceof String string) state.addText(string);
else if (!(value instanceof Number) && !(value instanceof Boolean)) {
throw new IllegalArgumentException("unsupported translatable argument type: "
+ (value == null ? "null" : value.getClass().getName()));
}
}
}
var style = component.style();
if (style.insertion() != null || style.font() != null || style.shadowColor() != null) {
throw new IllegalArgumentException("component style contains a field unsupported by Minecraft 1.7.10");
}
var click = style.clickEvent();
if (click != null && click.action() != net.kyori.adventure.text.event.ClickEvent.Action.OPEN_URL
&& click.action() != net.kyori.adventure.text.event.ClickEvent.Action.RUN_COMMAND
&& click.action() != net.kyori.adventure.text.event.ClickEvent.Action.SUGGEST_COMMAND) {
throw new IllegalArgumentException("unsupported Minecraft 1.7.10 click action: " + click.action());
}
if (click != null) {
if (!(click.payload() instanceof net.kyori.adventure.text.event.ClickEvent.Payload.Text payload)) {
throw new IllegalArgumentException("supported Minecraft 1.7.10 click actions require a text payload");
}
state.addText(payload.value());
}
var hover = style.hoverEvent();
if (hover != null && hover.action() != net.kyori.adventure.text.event.HoverEvent.Action.SHOW_TEXT) {
throw new IllegalArgumentException("unsupported Minecraft 1.7.10 hover action: " + hover.action());
}
if (hover != null) {
if (!(hover.value() instanceof Component hoverText)) {
throw new IllegalArgumentException("show_text hover value must be a text component");
}
validateAdventureNodes(hoverText, depth + 1, state);
}
for (Component child : component.children()) validateAdventureNodes(child, depth + 1, state);
}
private static final class ValidationState {
private int count;
private int inputBytes;
private void addText(String value) {
if (value.length() > MAX_JSON_BYTES) {
throw new IllegalArgumentException("component text exceeds " + MAX_JSON_BYTES + " bytes");
}
int bytes = value.getBytes(StandardCharsets.UTF_8).length;
inputBytes += bytes;
if (inputBytes > MAX_JSON_BYTES) {
throw new IllegalArgumentException("component text exceeds " + MAX_JSON_BYTES + " bytes");
}
}
}
}
@@ -38,6 +38,8 @@ import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.List;
import net.kyori.adventure.text.Component;
import dev.strataproxy.core.protocol.MinecraftText;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
@@ -56,7 +58,7 @@ import java.util.concurrent.ThreadFactory;
public final class ProxySessionListener implements Players {
@FunctionalInterface public interface CommandDispatcher {
/** Returns true only if this proxy owns the command. */
boolean dispatch(PlayerView player, String message, Consumer<String> reply, BooleanSupplier admission);
boolean dispatch(PlayerView player, String message, Consumer<Component> reply, BooleanSupplier admission);
}
private final SocketAddress bindAddress;
private final BackendCatalog catalog;
@@ -77,6 +79,9 @@ public final class ProxySessionListener implements Players {
private final AtomicInteger connectionCount = new AtomicInteger();
private volatile Function<PlayerView, CompletionStage<Optional<PlacementDecision>>> placement;
private volatile CommandDispatcher commandDispatcher;
private Function<String, List<String>> commandNames;
private java.util.function.BiFunction<PlayerView, String, Optional<CompletionStage<List<String>>>> completions;
private EventDispatcher events;
private Duration eventTimeout = Duration.ofSeconds(5);
private int maxConnections = 4096;
@@ -177,6 +182,17 @@ public final class ProxySessionListener implements Players {
commandDispatcher = Objects.requireNonNull(dispatcher, "dispatcher");
}
public synchronized void setCommandCompletion(Function<String, List<String>> names,
java.util.function.BiFunction<PlayerView, String, Optional<CompletionStage<List<String>>>> handler) {
if (started || closed || completions != null) throw new IllegalStateException("Completion must be configured before start");
commandNames = Objects.requireNonNull(names);
completions = Objects.requireNonNull(handler);
}
Function<String, List<String>> commandNames() { return commandNames; }
Optional<CompletionStage<List<String>>> completeCommand(PlayerView player, String text) {
return completions.apply(player, text);
}
/** Bounds accepted sessions, including clients that have not completed login. */
public synchronized void setMaxConnections(int limit) {
if (started || closed) throw new IllegalStateException("Connection limit must be set before listener start");
@@ -291,24 +307,24 @@ public final class ProxySessionListener implements Players {
return session.transferTo(backendName);
}
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message) {
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, Component message) {
Objects.requireNonNull(identity, "identity");
Session.validateMessage(message);
String encoded = MinecraftText.encode(message);
Session session = sessionsByPlayerId.get(identity.playerId());
if (session == null || !session.matchesIdentity(identity)) {
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
}
return session.sendMessage(message);
return session.sendEncodedMessage(encoded);
}
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, Component reason) {
Objects.requireNonNull(identity, "identity");
Session.validateDisconnectReason(reason);
String encoded = MinecraftText.encodeReason(reason);
Session session = sessionsByPlayerId.get(identity.playerId());
if (session == null || !session.matchesIdentity(identity)) {
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
}
return session.disconnect(reason);
return session.disconnectEncoded(encoded);
}
public synchronized CompletionStage<Void> close() {
@@ -53,6 +53,8 @@ import java.util.UUID;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import net.kyori.adventure.text.Component;
import dev.strataproxy.core.protocol.MinecraftText;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.RejectedExecutionException;
@@ -100,6 +102,7 @@ final class Session extends ChannelInboundHandlerAdapter {
private boolean disconnectPacketStarted;
private boolean disconnectRelayDrained;
private String pendingDisconnectReason;
private TabCompletionBridge tabCompletion;
private CompletableFuture<DisconnectResult> disconnectResult;
private ScheduledFuture<?> disconnectDeadline;
private int commandTokens = COMMAND_BURST;
@@ -428,7 +431,15 @@ final class Session extends ChannelInboundHandlerAdapter {
pipeline.addLast("minecraft-frame-encoder", new SessionFrameEncoder());
}
private void disconnectLogin(String reason) {
private void disconnectLogin(String reason) { disconnectLogin(Component.text(reason)); }
private void disconnectLogin(Component message) {
String reason;
try { reason = MinecraftText.encodeReason(message); }
catch (RuntimeException invalid) {
LOGGER.warn("Invalid plugin login rejection message", invalid);
reason = MinecraftText.encodeReason(Component.text("Connection rejected."));
}
if (frontend.eventLoop().inEventLoop()) beginDisconnect(reason);
else requestDisconnect(reason);
}
@@ -460,6 +471,7 @@ final class Session extends ChannelInboundHandlerAdapter {
if (id == 0) { // Login Disconnect.
loginDisconnectStarted = true;
disconnecting = true;
if (tabCompletion != null) tabCompletion.close();
disconnectResult = new CompletableFuture<>();
frontend.config().setAutoRead(false);
resetLoginDeadline(LOGIN_DISCONNECT_DRAIN_TIMEOUT);
@@ -556,6 +568,7 @@ final class Session extends ChannelInboundHandlerAdapter {
this::dispatchPlayerCommand, this::closePair));
}
target.pipeline().addLast("keep-alive-bridge", new KeepAliveBridge(keepAlives, false, this::closePair));
installTabCompletion(target);
var link = RawRelay.attach(frontend, target,
bytes -> observation.observeFrame(false, bytes),
bytes -> observation.observeFrame(true, bytes));
@@ -581,6 +594,16 @@ final class Session extends ChannelInboundHandlerAdapter {
}
}
private void installTabCompletion(Channel target) {
if (owner.commandNames() == null) return;
if (tabCompletion != null) tabCompletion.close();
if (frontend.pipeline().get("tab-completion") != null) frontend.pipeline().remove("tab-completion");
tabCompletion = new TabCompletionBridge(frontend, owner.commandNames(),
text -> owner.completeCommand(view, text), this::closePair);
frontend.pipeline().addLast("tab-completion", tabCompletion.frontendHandler());
target.pipeline().addLast("tab-completion", tabCompletion.backendHandler());
}
private boolean dispatchPlayerCommand(String message) {
if (closed.get() || disconnecting || !published) return false;
return owner.commandDispatcher().dispatch(view, message, this::sendCommandReply, this::admitPlayerCommand);
@@ -601,13 +624,21 @@ final class Session extends ChannelInboundHandlerAdapter {
if (!commandNoticeSent || now - lastCommandNoticeNanos >= COMMAND_NOTICE_NANOS) {
commandNoticeSent = true;
lastCommandNoticeNanos = now;
sendCommandReply("Too many proxy commands. Please slow down.");
sendCommandReply(Component.text("Too many proxy commands. Please slow down."));
}
return false;
}
CompletionStage<MessageResult> sendMessage(String message) {
validateMessage(message);
return sendMessage(Component.text(message));
}
CompletionStage<MessageResult> sendMessage(Component message) {
return sendEncodedMessage(MinecraftText.encode(message));
}
CompletionStage<MessageResult> sendEncodedMessage(String message) {
CompletableFuture<MessageResult> result = new CompletableFuture<>();
if (closed.get() || disconnecting) {
result.complete(MessageResult.NOT_CONNECTED);
@@ -645,7 +676,7 @@ final class Session extends ChannelInboundHandlerAdapter {
return;
}
try {
ByteBuf frame = Minecraft1710PlayPackets.chatReply(frontend.alloc(), message);
ByteBuf frame = Minecraft1710PlayPackets.chatReplyEncoded(frontend.alloc(), message);
frontend.writeAndFlush(frame).addListener(write -> {
pendingMessages.decrementAndGet();
if (write.isSuccess()) result.complete(MessageResult.SENT);
@@ -683,12 +714,16 @@ final class Session extends ChannelInboundHandlerAdapter {
if (disconnecting) writeDisconnectReasonIfDrained();
}
private void sendCommandReply(String message) {
private void sendCommandReply(Component message) {
sendMessage(message);
}
CompletionStage<DisconnectResult> disconnect(String reason) {
validateDisconnectReason(reason);
return requestDisconnect(MinecraftText.encodeReason(Component.text(reason)));
}
CompletionStage<DisconnectResult> disconnectEncoded(String reason) {
return requestDisconnect(reason);
}
@@ -722,6 +757,7 @@ final class Session extends ChannelInboundHandlerAdapter {
private void beginDisconnect(String reason) {
if (closed.get() || disconnecting) return;
disconnecting = true;
if (tabCompletion != null) tabCompletion.close();
loginDisconnectStarted = true;
pendingDisconnectReason = reason;
disconnectResult = new CompletableFuture<>();
@@ -766,7 +802,7 @@ final class Session extends ChannelInboundHandlerAdapter {
try {
ByteBuf packet;
if (frontendPlayPhase) {
ByteBuf payload = Minecraft1710PlayPackets.disconnect(frontend.alloc(), pendingDisconnectReason);
ByteBuf payload = Minecraft1710PlayPackets.disconnectEncoded(frontend.alloc(), pendingDisconnectReason);
try {
packet = frontend.pipeline().get("minecraft-frame-encoder") == null
? Minecraft1710PlayPackets.frame(frontend.alloc(), payload) : payload.copy();
@@ -774,7 +810,7 @@ final class Session extends ChannelInboundHandlerAdapter {
payload.release();
}
} else {
packet = MinecraftLoginDisconnect.encode(frontend.alloc(), pendingDisconnectReason);
packet = MinecraftLoginDisconnect.encodeJson(frontend.alloc(), pendingDisconnectReason);
}
frontend.writeAndFlush(packet).addListener(ignored -> closePair());
} catch (RuntimeException failure) {
@@ -1137,6 +1173,7 @@ final class Session extends ChannelInboundHandlerAdapter {
attempt.channel.pipeline().addLast("keep-alive-bridge",
new KeepAliveBridge(keepAlives, false, this::closePair));
installTransferFrameHandlers(attempt.frameState, attempt.channel);
installTabCompletion(attempt.channel);
next = RawRelay.attach(frontend, attempt.channel,
bytes -> nextObservation.observeFrame(false, bytes),
bytes -> nextObservation.observeFrame(true, bytes));
@@ -1321,6 +1358,7 @@ final class Session extends ChannelInboundHandlerAdapter {
void closePair() {
if (!closed.compareAndSet(false, true)) return;
Runnable cleanup = () -> {
if (tabCompletion != null) tabCompletion.close();
if (initialLoginDeadline != null) initialLoginDeadline.cancel(false);
if (initialPlayDeadline != null) initialPlayDeadline.cancel(false);
if (disconnectDeadline != null) disconnectDeadline.cancel(false);
@@ -0,0 +1,193 @@
package dev.strataproxy.core.session;
import dev.strataproxy.core.protocol.MinecraftTabCompletion;
import dev.strataproxy.core.relay.RawRelay;
import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.ReferenceCountUtil;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/** One backend generation, confined to the shared frontend/backend event loop. */
final class TabCompletionBridge implements AutoCloseable {
private final Channel frontend;
private final Function<String, List<String>> roots;
private final Function<String, Optional<CompletionStage<List<String>>>> complete;
private final Runnable closeSession;
private final ArrayDeque<Request> queued = new ArrayDeque<>();
private Request active;
private boolean backendReplyOutstanding;
private boolean closed;
private static final class Request {
final ChannelHandlerContext context;
final ByteBuf frame;
final String text;
boolean owned = true;
Request(ChannelHandlerContext context, ByteBuf frame, String text) {
this.context = context; this.frame = frame; this.text = text;
}
void release() { if (owned) { owned = false; frame.release(); } }
void forward() { owned = false; context.fireChannelRead(frame); }
}
private ScheduledFuture<?> timeout;
private CompletableFuture<?> pluginResult;
TabCompletionBridge(Channel frontend, Function<String, List<String>> roots,
Function<String, Optional<CompletionStage<List<String>>>> complete,
Runnable closeSession) {
this.frontend = frontend;
this.roots = roots;
this.complete = complete;
this.closeSession = closeSession;
}
ChannelInboundHandlerAdapter frontendHandler() { return new Handler(true); }
ChannelInboundHandlerAdapter backendHandler() { return new Handler(false); }
private final class Handler extends ChannelInboundHandlerAdapter {
private final boolean fromClient;
private Handler(boolean fromClient) { this.fromClient = fromClient; }
@Override public void channelRead(ChannelHandlerContext ctx, Object message) {
if (!(message instanceof ByteBuf frame)) {
ReferenceCountUtil.release(message);
closeSession.run();
return;
}
if (!MinecraftTabCompletion.matches(frame,
fromClient ? MinecraftTabCompletion.REQUEST : MinecraftTabCompletion.RESPONSE)) {
ctx.fireChannelRead(frame);
return;
}
try {
if (fromClient) {
if (closed) { frame.release(); return; }
String text = MinecraftTabCompletion.request(frame);
if (queued.size() >= 8) {
frame.release();
closeSession.run();
return;
}
queued.add(new Request(ctx, frame, text));
pump();
} else {
try { acceptBackend(frame); } finally { frame.release(); }
}
} catch (RuntimeException invalid) {
// A request not admitted to the queue is still ours.
if (fromClient && frame.refCnt() > 0 && queued.stream().noneMatch(r -> r.frame == frame)
&& (active == null || active.frame != frame)) frame.release();
closeSession.run();
} finally {
RawRelay.continueAfterDrop(ctx.channel());
}
}
}
private void pump() {
if (closed || active != null || queued.isEmpty()) return;
Request request = queued.removeFirst();
active = request;
boolean root = isRoot(request.text);
Optional<CompletionStage<List<String>>> local = root ? Optional.empty() : complete.apply(request.text);
if (local.isPresent()) {
request.release();
CompletableFuture<List<String>> future = local.get().toCompletableFuture();
pluginResult = future;
timeout = frontend.eventLoop().schedule(() -> {
if (active != request) return;
finish(List.of());
future.cancel(false);
}, 1, TimeUnit.SECONDS);
future.whenComplete((values, error) -> {
try {
frontend.eventLoop().execute(() -> {
if (!closed && active == request) finish(error == null ? values : List.of());
});
} catch (java.util.concurrent.RejectedExecutionException ignored) { }
});
} else if (backendReplyOutstanding) {
// An expired backend request has no wire ID. Do not issue another until its reply arrives.
request.release();
finish(root ? roots.apply(request.text.substring(1)) : List.of());
} else {
backendReplyOutstanding = true;
timeout = frontend.eventLoop().schedule(() -> {
if (active == request) finish(root ? roots.apply(request.text.substring(1)) : List.of());
}, 1, TimeUnit.SECONDS);
request.forward();
}
}
private static boolean isRoot(String text) {
return text.startsWith("/") && text.chars().noneMatch(Character::isWhitespace);
}
private void acceptBackend(ByteBuf frame) {
if (closed || !backendReplyOutstanding) return;
backendReplyOutstanding = false;
if (active == null || pluginResult != null) return; // expired request; never deliver its reply
if (!isRoot(active.text)) { finish(List.of(), frame); return; }
List<String> values = MinecraftTabCompletion.response(frame);
if (isRoot(active.text)) {
LinkedHashSet<String> merged = new LinkedHashSet<>(roots.apply(active.text.substring(1)));
merged.addAll(values);
values = new ArrayList<>(merged).subList(0, Math.min(100, merged.size()));
}
finish(values);
}
private void finish(List<String> values) {
finish(values, null);
}
private void finish(List<String> values, ByteBuf original) {
if (timeout != null) timeout.cancel(false);
timeout = null;
pluginResult = null;
active = null;
if (!closed && frontend.isActive() && frontend.isWritable()) {
try {
frontend.writeAndFlush(original == null
? MinecraftTabCompletion.response(frontend.alloc(), bounded(values)) : original.retainedDuplicate())
.addListener(write -> { if (!write.isSuccess()) closeSession.run(); });
} catch (RuntimeException failure) { closeSession.run(); return; }
}
pump();
}
private static List<String> bounded(List<String> values) {
List<String> result = new ArrayList<>();
int bytes = 2; // packet ID and result count (at most 100)
for (String value : values) {
if (result.size() == 100) break;
if (value == null || value.length() > 100) continue;
int length = value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
int framed = length + (length < 128 ? 1 : 2);
if (bytes + framed > 32767) break;
bytes += framed;
result.add(value);
}
return result;
}
@Override public void close() {
if (closed) return;
closed = true;
if (timeout != null) timeout.cancel(false);
if (pluginResult != null) pluginResult.cancel(false);
if (active != null) active.release();
active = null;
Request request;
while ((request = queued.pollFirst()) != null) request.release();
}
}
@@ -0,0 +1,218 @@
package dev.strataproxy.core.plugin;
import dev.strataproxy.api.CommandCompletion;
import dev.strataproxy.api.CommandRegistration;
import dev.strataproxy.api.PlayerIdentity;
import dev.strataproxy.api.PlayerView;
import dev.strataproxy.api.Plugin;
import dev.strataproxy.api.PluginContext;
import dev.strataproxy.api.Players;
import dev.strataproxy.api.MessageResult;
import dev.strataproxy.api.DisconnectResult;
import dev.strataproxy.api.TransferResult;
import dev.strataproxy.core.backend.InMemoryBackendCatalog;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PluginHostCompletionTest {
private static final PlayerView PLAYER = new PlayerView(
new PlayerIdentity(UUID.randomUUID(), 1), "TestPlayer", Optional.empty());
@Test
void commandRootsAndArgumentCompletionPreserveWhitespaceAndBoundSuggestions() throws Exception {
var request = new CompletableFuture<CommandCompletion>();
var emptyArguments = new CompletableFuture<CommandCompletion>();
var callbackThread = new AtomicReference<Thread>();
var candidateList = new ArrayList<String>(Arrays.asList(
"alpha", "alpha", null, "two words", "x".repeat(101), "猫".repeat(11_000), "beta"));
for (int i = 0; i < 120; i++) candidateList.add("item" + i);
CompletableFuture<List<String>> suggestions = CompletableFuture.completedFuture(candidateList);
Plugin plugin = new Plugin() {
@Override public void onLoad(PluginContext value) {
value.commands().register("Ping", ignored -> { }, completion -> {
callbackThread.set(Thread.currentThread());
if (completion.arguments().isEmpty()) emptyArguments.complete(completion);
else request.complete(completion);
return suggestions;
});
value.commands().register("alpha", ignored -> { });
}
};
try (var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1))) {
host.load(List.of(plugin));
host.enable();
assertEquals(List.of("/alpha", "/ping"), host.commandNames(""));
assertEquals(List.of("/ping"), host.commandNames("P"));
assertTrue(host.completeCommand(PLAYER, "/unknown arg").isEmpty());
assertTrue(host.completeCommand(PLAYER, "/ping").isEmpty(), "root-only completion belongs to commandNames");
CompletionStage<List<String>> stage = host.completeCommand(PLAYER, "/PiNg first ").orElseThrow();
assertEquals("ping", request.get(5, TimeUnit.SECONDS).commandName());
assertEquals(" first ", request.get(5, TimeUnit.SECONDS).arguments());
assertNotEquals(Thread.currentThread(), callbackThread.get(), "plugin completer must run on a worker");
List<String> completed = stage.toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals(List.of("alpha", "beta"), completed.subList(0, 2));
assertEquals(100, completed.size());
CompletionStage<List<String>> emptyStage = host.completeCommand(PLAYER, "/ping ").orElseThrow();
assertEquals("", emptyArguments.get(5, TimeUnit.SECONDS).arguments());
assertEquals(100, emptyStage.toCompletableFuture().get(5, TimeUnit.SECONDS).size());
assertEquals(List.of(), host.completeCommand(PLAYER, "/alpha ").orElseThrow()
.toCompletableFuture().get(1, TimeUnit.SECONDS));
}
}
@Test
void closingHostCancelsPendingCompleterAndCompletesLocalResult() throws Exception {
var invoked = new CountDownLatch(1);
var pluginStageCancelled = new CountDownLatch(1);
var pluginStage = new CompletableFuture<List<String>>();
pluginStage.whenComplete((ignored, failure) -> pluginStageCancelled.countDown());
Plugin plugin = new Plugin() {
@Override public void onLoad(PluginContext context) {
context.commands().register("wait", ignored -> { }, completion -> {
invoked.countDown();
return pluginStage;
});
}
};
var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1));
host.load(List.of(plugin));
host.enable();
CompletableFuture<List<String>> local = host.completeCommand(PLAYER, "/wait ").orElseThrow()
.toCompletableFuture();
assertTrue(invoked.await(5, TimeUnit.SECONDS));
host.close();
ExecutionException failure = org.junit.jupiter.api.Assertions.assertThrows(ExecutionException.class,
() -> local.get(1, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof IllegalStateException);
assertTrue(pluginStageCancelled.await(1, TimeUnit.SECONDS));
assertTrue(pluginStage.isCancelled(), "host close should cancel the plugin's unfinished stage");
}
@Test
void timeoutCancelsPluginStageOffTheTimerThread() throws Exception {
var invoked = new CountDownLatch(1);
var cancellationCallback = new CountDownLatch(1);
var callbackThread = new AtomicReference<Thread>();
var pluginStage = new CompletableFuture<List<String>>();
pluginStage.whenComplete((ignored, failure) -> {
callbackThread.set(Thread.currentThread());
cancellationCallback.countDown();
});
Plugin plugin = new Plugin() {
@Override public void onLoad(PluginContext context) {
context.commands().register("wait", ignored -> { }, completion -> {
invoked.countDown();
return pluginStage;
});
}
};
try (var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1))) {
host.load(List.of(plugin));
host.enable();
CompletableFuture<List<String>> local = host.completeCommand(PLAYER, "/wait ").orElseThrow()
.toCompletableFuture();
assertTrue(invoked.await(5, TimeUnit.SECONDS));
ExecutionException failure = org.junit.jupiter.api.Assertions.assertThrows(ExecutionException.class,
() -> local.get(3, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof java.util.concurrent.TimeoutException);
assertTrue(cancellationCallback.await(1, TimeUnit.SECONDS));
assertTrue(pluginStage.isCancelled());
assertNotEquals(Thread.currentThread(), callbackThread.get());
assertTrue(callbackThread.get().getName().startsWith("strataproxy-plugin-player-"));
}
}
@Test
void unregisterCancelsPendingCompletionAndRemovesTheRoot() throws Exception {
var invoked = new CountDownLatch(1);
var cancellationCallback = new CountDownLatch(1);
var pluginStage = new CompletableFuture<List<String>>();
pluginStage.whenComplete((ignored, failure) -> cancellationCallback.countDown());
var registration = new AtomicReference<CommandRegistration>();
Plugin plugin = new Plugin() {
@Override public void onLoad(PluginContext context) {
registration.set(context.commands().register("wait", ignored -> { }, completion -> {
invoked.countDown();
return pluginStage;
}));
}
};
try (var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1))) {
host.load(List.of(plugin));
host.enable();
CompletableFuture<List<String>> local = host.completeCommand(PLAYER, "/wait ").orElseThrow()
.toCompletableFuture();
assertTrue(invoked.await(5, TimeUnit.SECONDS));
registration.get().unregister();
ExecutionException failure = org.junit.jupiter.api.Assertions.assertThrows(ExecutionException.class,
() -> local.get(1, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof IllegalStateException);
assertTrue(cancellationCallback.await(1, TimeUnit.SECONDS));
assertTrue(pluginStage.isCancelled());
assertEquals(List.of(), host.commandNames("wait"));
assertTrue(host.completeCommand(PLAYER, "/wait ").isEmpty());
}
}
@Test
void completerThrowAndNullStageFailPromptly() throws Exception {
Plugin plugin = new Plugin() {
@Override public void onLoad(PluginContext context) {
context.commands().register("throws", ignored -> { }, completion -> {
throw new IllegalArgumentException("bad completer");
});
context.commands().register("nullstage", ignored -> { }, completion -> null);
}
};
try (var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1))) {
host.load(List.of(plugin));
host.enable();
for (String command : List.of("throws", "nullstage")) {
CompletableFuture<List<String>> local = host.completeCommand(PLAYER, "/" + command + " ")
.orElseThrow().toCompletableFuture();
ExecutionException failure = org.junit.jupiter.api.Assertions.assertThrows(ExecutionException.class,
() -> local.get(500, TimeUnit.MILLISECONDS));
assertFalse(failure.getCause() instanceof java.util.concurrent.TimeoutException);
}
}
}
private static Players players() {
return new Players() {
@Override public Optional<PlayerView> find(PlayerIdentity identity) { return Optional.empty(); }
@Override public List<PlayerView> online() { return List.of(); }
@Override public CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName) {
return CompletableFuture.completedFuture(TransferResult.failed("unused"));
}
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, net.kyori.adventure.text.Component message) {
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
}
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, net.kyori.adventure.text.Component reason) {
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
}
};
}
}
@@ -23,6 +23,8 @@ import dev.strataproxy.api.ServerDefinition;
import dev.strataproxy.api.ServerRegistration;
import dev.strataproxy.api.TransferResult;
import dev.strataproxy.api.TransferStatus;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextComponent;
import dev.strataproxy.core.backend.BackendCatalog;
import dev.strataproxy.core.backend.BackendHandle;
import dev.strataproxy.core.backend.BackendId;
@@ -537,7 +539,7 @@ class PluginHostTest {
host.load(List.of(plugin));
var observed = new CompletableFuture<CommandInvocation>();
var worker = new CompletableFuture<Thread>();
var reply = new CompletableFuture<String>();
var reply = new CompletableFuture<Component>();
var registration = plugin.context.commands().register("Ping", invocation -> {
worker.complete(Thread.currentThread());
observed.complete(invocation);
@@ -555,7 +557,7 @@ class PluginHostTest {
assertNotEquals(Thread.currentThread(), worker.get(5, TimeUnit.SECONDS));
assertEquals("ping", observed.get(5, TimeUnit.SECONDS).name());
assertEquals("hello world", observed.get(5, TimeUnit.SECONDS).arguments());
assertEquals("pong", reply.get(5, TimeUnit.SECONDS));
assertEquals("pong", ((TextComponent) reply.get(5, TimeUnit.SECONDS)).content());
registration.unregister();
registration.unregister();
assertFalse(host.dispatchCommand(PLAYER, "/ping", text -> { }));
@@ -616,10 +618,10 @@ class PluginHostTest {
assertTrue(host.dispatchCommand(PLAYER, "/slow one", text -> { }));
assertTrue(entered.await(5, TimeUnit.SECONDS));
assertTrue(host.dispatchCommand(PLAYER, "/slow two", text -> { }));
var rejectedReply = new CompletableFuture<String>();
var rejectedReply = new CompletableFuture<Component>();
assertTrue(host.dispatchCommand(PLAYER, "/slow three", rejectedReply::complete));
assertEquals("Proxy command service is busy. Please try again.",
rejectedReply.get(1, TimeUnit.SECONDS));
((TextComponent) rejectedReply.get(1, TimeUnit.SECONDS)).content());
assertEquals(1, calls.get());
} finally {
release.countDown();
@@ -782,15 +784,15 @@ class PluginHostTest {
@Override public java.util.concurrent.CompletionStage<TransferResult> transfer(
PlayerIdentity identity, String backendName) { throw new AssertionError("unexpected transfer"); }
@Override public java.util.concurrent.CompletionStage<MessageResult> sendMessage(
PlayerIdentity identity, String message) {
PlayerIdentity identity, Component message) {
assertEquals(PLAYER.identity(), identity);
assertEquals("hello", message);
assertEquals("hello", ((TextComponent) message).content());
return messageResult;
}
@Override public java.util.concurrent.CompletionStage<DisconnectResult> disconnect(
PlayerIdentity identity, String reason) {
PlayerIdentity identity, Component reason) {
assertEquals(PLAYER.identity(), identity);
assertEquals("maintenance", reason);
assertEquals("maintenance", ((TextComponent) reason).content());
return disconnectResult;
}
};
@@ -994,11 +996,11 @@ class PluginHostTest {
private abstract static class TestPlayers implements Players {
@Override public java.util.concurrent.CompletionStage<MessageResult> sendMessage(
PlayerIdentity identity, String message) {
PlayerIdentity identity, Component message) {
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
}
@Override public java.util.concurrent.CompletionStage<DisconnectResult> disconnect(
PlayerIdentity identity, String reason) {
PlayerIdentity identity, Component reason) {
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
}
}
@@ -0,0 +1,117 @@
package dev.strataproxy.core.protocol;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MinecraftTextTest {
private static final ObjectMapper JSON = new ObjectMapper();
@Test
void templateVariablesRemainLiteralAndPrimitiveArgumentsAreBounded() throws Exception {
var message = net.kyori.adventure.text.minimessage.MiniMessage.miniMessage().deserialize(
"<green>Hello <player></green>",
net.kyori.adventure.text.minimessage.tag.resolver.Placeholder.unparsed("player", "<red>Ada"));
var encoded = JSON.readTree(MinecraftText.encode(message));
assertTrue(encoded.toString().contains("<red>Ada"));
var arguments = new java.util.ArrayList<net.kyori.adventure.text.TranslationArgument>();
for (int i = 0; i < 300; i++) arguments.add(net.kyori.adventure.text.TranslationArgument.numeric(i));
assertThrows(IllegalArgumentException.class,
() -> MinecraftText.encode(Component.translatable("key").arguments(arguments)));
}
@Test
void writesLegacyTextTranslationStyleClickAndHoverJson() throws Exception {
Component component = Component.translatable("chat.type.text", Component.text("Ada"),
Component.text("hello"))
.color(NamedTextColor.RED)
.decorate(TextDecoration.BOLD)
.clickEvent(ClickEvent.runCommand("/hello"))
.hoverEvent(HoverEvent.showText(Component.text("Show details")));
JsonNode encoded = JSON.readTree(MinecraftText.encode(component));
assertEquals("chat.type.text", encoded.path("translate").textValue());
assertEquals(2, encoded.path("with").size());
assertEquals("red", encoded.path("color").textValue());
assertTrue(encoded.path("bold").booleanValue());
assertEquals("run_command", encoded.path("clickEvent").path("action").textValue());
assertEquals("/hello", encoded.path("clickEvent").path("value").textValue());
assertEquals("show_text", encoded.path("hoverEvent").path("action").textValue());
JsonNode hoverValue = encoded.path("hoverEvent").path("value");
assertEquals("Show details", hoverValue.isTextual()
? hoverValue.textValue() : hoverValue.path("text").textValue());
assertTrue(encoded.path("hoverEvent").has("value"));
assertTrue(!encoded.path("hoverEvent").has("contents"));
}
@Test
void supportsTextExtraAndAllThreeMinecraftClickActions() throws Exception {
for (ClickEvent<?> click : new ClickEvent<?>[]{
ClickEvent.openUrl("https://example.test"),
ClickEvent.runCommand("/home"),
ClickEvent.suggestCommand("/spawn")}) {
Component component = Component.text("go").clickEvent(click)
.append(Component.text(" now", NamedTextColor.GREEN));
JsonNode encoded = JSON.readTree(MinecraftText.encode(component));
assertEquals(click.action().toString().toLowerCase(), encoded.path("clickEvent").path("action").textValue());
assertEquals(" now", encoded.path("extra").get(0).path("text").textValue());
}
}
@Test
void rejectsComponentKindsAndEventsWithoutMinecraft1710Support() {
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.keybind("key.jump")));
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.selector("@a")));
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.text("x")
.clickEvent(ClickEvent.copyToClipboard("x"))));
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.text("x")
.hoverEvent(HoverEvent.showItem(Key.key("minecraft:stone"), 1))));
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.text("x")
.insertion("insert")));
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(
Component.translatable("hello").fallback("Hello")));
}
@Test
void rejectsBlankReasonsAndBoundsTheTreeBeforeSerialization() {
assertThrows(IllegalArgumentException.class,
() -> MinecraftText.encodeReason(Component.text(" \n\t")));
Component tooDeep = Component.text("leaf");
for (int i = 0; i < 32; i++) tooDeep = Component.text("x").hoverEvent(HoverEvent.showText(tooDeep));
Component deep = tooDeep;
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(deep));
Component[] children = new Component[256];
for (int i = 0; i < children.length; i++) children[i] = Component.text("x");
Component wide = Component.textOfChildren(children);
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(wide));
assertThrows(IllegalArgumentException.class,
() -> MinecraftText.encode(Component.text("x".repeat(MinecraftText.MAX_JSON_BYTES + 1))));
}
@Test
void packetEncodersRejectOversizedPreencodedJsonBeforeAllocation() {
String tooLarge = "\"" + "x".repeat(MinecraftText.MAX_JSON_BYTES) + "\"";
assertThrows(IllegalArgumentException.class, () -> Minecraft1710PlayPackets.chatReplyEncoded(
io.netty.buffer.UnpooledByteBufAllocator.DEFAULT, tooLarge));
assertThrows(IllegalArgumentException.class, () -> Minecraft1710PlayPackets.disconnectEncoded(
io.netty.buffer.UnpooledByteBufAllocator.DEFAULT, tooLarge));
assertThrows(IllegalArgumentException.class, () -> MinecraftLoginDisconnect.encodeJson(
io.netty.buffer.UnpooledByteBufAllocator.DEFAULT, tooLarge));
assertNotNull(MinecraftText.encode(Component.text("valid")));
}
}
@@ -767,6 +767,19 @@ final class ProxySessionListenerTest {
data.writeFloat(0);
data.writeByte(0);
writeFrame(output, position.toByteArray());
var rootRequest = new java.io.ByteArrayInputStream(readFrame(input));
assertEquals(0x14, readVarInt(rootRequest));
assertEquals("/w", readString(rootRequest, 32767));
var rootReply = new ByteArrayOutputStream();
writeVarInt(rootReply, 0x3a); writeVarInt(rootReply, 2);
writeString(rootReply, "/weather"); writeString(rootReply, "/where");
writeFrame(output, rootReply.toByteArray());
var unknownRequest = new java.io.ByteArrayInputStream(readFrame(input));
assertEquals(0x14, readVarInt(unknownRequest));
assertEquals("/weather c", readString(unknownRequest, 32767));
var unknownReply = new ByteArrayOutputStream();
writeVarInt(unknownReply, 0x3a); writeVarInt(unknownReply, 1); writeString(unknownReply, "clear");
writeFrame(output, unknownReply.toByteArray());
received.complete(List.of(readFrame(input), readFrame(input)));
} catch (Throwable failure) {
received.completeExceptionally(failure);
@@ -782,8 +795,11 @@ final class ProxySessionListenerTest {
@Override public void onLoad(PluginContext context) {
context.commands().register("where", invocation -> {
handlerThread.complete(Thread.currentThread().getName());
invocation.reply(invocation.player().username() + ":" + invocation.arguments());
});
invocation.reply(net.kyori.adventure.text.Component.text(
invocation.player().username() + ":" + invocation.arguments(),
net.kyori.adventure.text.format.NamedTextColor.GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/where lobby")));
}, completion -> { assertEquals("lo ", completion.arguments()); return CompletableFuture.completedFuture(List.of("lobby")); });
context.events().subscribe(ServerConnectedEvent.class, event -> {
assertFalse(Thread.currentThread().getName().startsWith("strataproxy-session-io"));
notificationCount.incrementAndGet();
@@ -809,6 +825,7 @@ final class ProxySessionListenerTest {
listener.setPlacement(plugins::placeInitial);
listener.setEvents(plugins, Duration.ofSeconds(5));
listener.setCommandDispatcher(plugins::dispatchCommand);
listener.setCommandCompletion(plugins::commandNames, plugins::completeCommand);
int port = ((InetSocketAddress) listener.start().toCompletableFuture()
.get(5, TimeUnit.SECONDS).localAddress()).getPort();
try (Socket client = new Socket(InetAddress.getLoopbackAddress(), port)) {
@@ -825,8 +842,17 @@ final class ProxySessionListenerTest {
writeFrame(output, chatPacket("/where lobby"));
var reply = new java.io.ByteArrayInputStream(readFrame(input));
assertEquals(2, readVarInt(reply));
assertEquals("CommandUser:lobby",
new ObjectMapper().readTree(readString(reply, 32767)).path("text").asText());
var replyJson = new ObjectMapper().readTree(readString(reply, 32767));
assertEquals("CommandUser:lobby", replyJson.path("text").asText());
assertEquals("green", replyJson.path("color").asText());
assertEquals("suggest_command", replyJson.path("clickEvent").path("action").asText());
assertEquals("/where lobby", replyJson.path("clickEvent").path("value").asText());
writeFrame(output, completionRequest("/where lo "));
assertEquals(List.of("lobby"), completionResponse(readFrame(input)));
writeFrame(output, completionRequest("/w"));
assertEquals(List.of("/where", "/weather"), completionResponse(readFrame(input)));
writeFrame(output, completionRequest("/weather c"));
assertEquals(List.of("clear"), completionResponse(readFrame(input)));
assertTrue(handlerThread.get(5, TimeUnit.SECONDS).startsWith("strataproxy-plugin-command-"));
assertEquals(2, accessCalls.get());
writeFrame(output, chatPacket("hello"));
@@ -844,6 +870,20 @@ final class ProxySessionListenerTest {
}
}
private static byte[] completionRequest(String text) throws Exception {
var packet = new ByteArrayOutputStream();
writeVarInt(packet, 0x14); writeString(packet, text);
return packet.toByteArray();
}
private static List<String> completionResponse(byte[] frame) throws Exception {
var packet = new java.io.ByteArrayInputStream(frame);
assertEquals(0x3a, readVarInt(packet));
int count = readVarInt(packet);
var values = new ArrayList<String>();
for (int i = 0; i < count; i++) values.add(readString(packet, 32767));
return values;
}
private static byte[] chatPacket(String text) throws Exception {
var packet = new ByteArrayOutputStream();
writeVarInt(packet, 1);
@@ -1611,7 +1651,7 @@ final class ProxySessionListenerTest {
listener.setCommandDispatcher((player, message, reply, admission) -> {
if (!message.equals("/ping")) return false;
if (!admission.getAsBoolean()) return true;
reply.accept("pong");
reply.accept(net.kyori.adventure.text.Component.text("pong"));
return true;
});
try {
@@ -87,6 +87,9 @@ final class SessionTransferTest {
register(catalog, "old", oldServer);
register(catalog, "new", newServer);
var listener = listener(catalog);
listener.setCommandCompletion(prefix -> List.of("/test"),
(player, text) -> Optional.of(CompletableFuture.completedFuture(
List.of(player.currentServer().orElseThrow()))));
try {
int port = ((InetSocketAddress) listener.start().toCompletableFuture()
.get(5, TimeUnit.SECONDS).localAddress()).getPort();
@@ -98,6 +101,7 @@ final class SessionTransferTest {
assertEquals(1, packetId(readFrame(input)));
assertEquals(8, packetId(readFrame(input)));
var player = awaitPlayer(listener);
assertLocalCompletion(client, input, "old");
var session = listener.allSessions().iterator().next();
var frontendField = Session.class.getDeclaredField("frontend");
@@ -144,6 +148,7 @@ final class SessionTransferTest {
assertEquals(8, packetId(readFrame(input)));
assertArrayEquals(new byte[]{0x01, 0x55}, newReceived.get(5, TimeUnit.SECONDS));
oldClosed.get(5, TimeUnit.SECONDS);
assertLocalCompletion(client, input, "new");
}
} finally { listener.close().toCompletableFuture().get(5, TimeUnit.SECONDS); }
}
@@ -1589,6 +1594,17 @@ final class SessionTransferTest {
thread.start();
}
private static void assertLocalCompletion(Socket client, DataInputStream input, String backend) throws Exception {
var body = new ByteArrayOutputStream();
var request = new DataOutputStream(body);
writeVarInt(request, 0x14); writeString(request, "/test ");
writeFrame(new DataOutputStream(client.getOutputStream()), body.toByteArray());
var response = new ByteArrayInputStream(readFrame(input));
assertEquals(0x3a, readVarInt(response));
assertEquals(1, readVarInt(response));
assertEquals(backend, new String(response.readNBytes(readVarInt(response)), StandardCharsets.UTF_8));
}
private static int packetId(byte[] bytes) throws Exception {
return readVarInt(new ByteArrayInputStream(bytes));
}
@@ -0,0 +1,175 @@
package dev.strataproxy.core.session;
import dev.strataproxy.core.protocol.Minecraft1710PlayPackets;
import dev.strataproxy.core.protocol.MinecraftTabCompletion;
import dev.strataproxy.core.protocol.ProtocolVarInt;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.embedded.EmbeddedChannel;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.*;
class TabCompletionBridgeTest {
private static ByteBuf request(String text) {
ByteBuf body = Unpooled.buffer();
body.writeByte(0x14);
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
ProtocolVarInt.write(body, bytes.length);
body.writeBytes(bytes);
try { return Minecraft1710PlayPackets.frame(UnpooledByteBufAllocatorHolder.ALLOC, body); }
finally { body.release(); }
}
private static final class UnpooledByteBufAllocatorHolder {
static final io.netty.buffer.ByteBufAllocator ALLOC = io.netty.buffer.UnpooledByteBufAllocator.DEFAULT;
}
private static List<String> read(EmbeddedChannel client) {
ByteBuf frame = client.readOutbound();
assertNotNull(frame);
try { return MinecraftTabCompletion.response(frame); } finally { frame.release(); }
}
private static final class Fixture implements AutoCloseable {
final EmbeddedChannel client = new EmbeddedChannel();
final EmbeddedChannel backend = new EmbeddedChannel();
final CompletableFuture<List<String>> result = new CompletableFuture<>();
final AtomicBoolean closed = new AtomicBoolean();
final TabCompletionBridge bridge = new TabCompletionBridge(client,
prefix -> List.of("/proxy"),
text -> text.startsWith("/proxy ") ? Optional.of(result) : Optional.empty(),
() -> { closed.set(true); });
Fixture() {
client.pipeline().addLast(bridge.frontendHandler());
backend.pipeline().addLast(bridge.backendHandler());
}
void backendReply(List<String> values) {
backend.writeInbound(MinecraftTabCompletion.response(backend.alloc(), values));
}
void releaseForwarded() { ByteBuf frame = client.readInbound(); assertNotNull(frame); frame.release(); }
@Override public void close() { bridge.close(); client.finishAndReleaseAll(); backend.finishAndReleaseAll(); }
}
@Test void mergesRootNamesAndDeduplicates() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/p")); f.releaseForwarded();
f.backendReply(List.of("/proxy", "/plugins"));
assertEquals(List.of("/proxy", "/plugins"), read(f.client));
assertFalse(f.closed.get());
}
}
@Test void localArgumentsNeverReachBackendAndCompleteOffCallback() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/proxy "));
assertNull(f.client.readInbound());
f.result.complete(List.of("lobby", "islands"));
f.client.runPendingTasks();
assertEquals(List.of("lobby", "islands"), read(f.client));
}
}
@Test void unknownCommandResponsePreservesExactFrame() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/backend arg")); f.releaseForwarded();
ByteBuf original = MinecraftTabCompletion.response(f.backend.alloc(), List.of("argument"));
ByteBuf expected = original.copy();
f.backend.writeInbound(original);
ByteBuf actual = f.client.readOutbound();
try { assertEquals(expected, actual); } finally { expected.release(); actual.release(); }
}
}
@Test void timedOutBackendReplyCannotCompleteNewLocalRequest() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/p")); f.releaseForwarded();
f.client.advanceTimeBy(2, TimeUnit.SECONDS); f.client.runScheduledPendingTasks();
assertEquals(List.of("/proxy"), read(f.client));
f.client.writeInbound(request("/proxy "));
f.backendReply(List.of("stale"));
assertNull(f.client.readOutbound());
f.result.complete(List.of("fresh")); f.client.runPendingTasks();
assertEquals(List.of("fresh"), read(f.client));
}
}
@Test void doesNotIssueAnotherBackendRequestUntilExpiredReplyIsConsumed() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/other arg")); f.releaseForwarded();
f.client.advanceTimeBy(2, TimeUnit.SECONDS); f.client.runScheduledPendingTasks(); read(f.client);
f.client.writeInbound(request("/other next"));
assertNull(f.client.readInbound()); assertEquals(List.of(), read(f.client));
f.backendReply(List.of("old")); assertNull(f.client.readOutbound());
f.client.writeInbound(request("/other last")); f.releaseForwarded();
f.backendReply(List.of("last")); assertEquals(List.of("last"), read(f.client));
}
}
@Test void closesGenerationAndCancelsPluginWorkReleasingQueuedFrames() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/proxy "));
ByteBuf queued = request("/p"); f.client.writeInbound(queued);
f.bridge.close();
assertTrue(f.result.isCancelled()); assertEquals(0, queued.refCnt());
f.client.runPendingTasks(); assertNull(f.client.readOutbound());
}
}
@Test void ordinaryFramesPassThroughByIdentity() {
try (Fixture f = new Fixture()) {
ByteBuf frame = Unpooled.wrappedBuffer(new byte[]{2, 1, 0});
f.client.writeInbound(frame);
assertSame(frame, f.client.readInbound()); frame.release();
}
}
@Test void localTimeoutReturnsEmptyAndCancelsStage() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/proxy "));
f.client.advanceTimeBy(2, TimeUnit.SECONDS); f.client.runScheduledPendingTasks();
assertEquals(List.of(), read(f.client)); assertTrue(f.result.isCancelled());
}
}
@Test void replacementGenerationIgnoresOldBackendReplies() {
try (Fixture f = new Fixture()) {
EmbeddedChannel replacement = new EmbeddedChannel();
f.client.writeInbound(request("/p")); f.releaseForwarded();
f.bridge.close();
f.client.pipeline().removeFirst();
var next = new TabCompletionBridge(f.client, prefix -> List.of("/new"),
text -> Optional.empty(), () -> f.closed.set(true));
try {
f.client.pipeline().addLast(next.frontendHandler());
replacement.pipeline().addLast(next.backendHandler());
f.client.writeInbound(request("/n")); f.releaseForwarded();
f.backendReply(List.of("/old"));
assertNull(f.client.readOutbound());
replacement.writeInbound(MinecraftTabCompletion.response(replacement.alloc(), List.of("/native")));
assertEquals(List.of("/new", "/native"), read(f.client));
} finally { next.close(); replacement.finishAndReleaseAll(); }
}
}
@Test void queuedRequestsStayInOrderAndOverflowClosesConnection() {
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/proxy "));
for (int i = 0; i < 8; i++) f.client.writeInbound(request("/p"));
ByteBuf overflow = request("/p"); f.client.writeInbound(overflow);
assertTrue(f.closed.get()); assertEquals(0, overflow.refCnt());
}
try (Fixture f = new Fixture()) {
f.client.writeInbound(request("/proxy "));
f.client.writeInbound(request("/p"));
assertNull(f.client.readInbound());
f.result.complete(List.of("first")); f.client.runPendingTasks();
assertEquals(List.of("first"), read(f.client));
f.releaseForwarded();
f.backendReply(List.of("/second"));
assertEquals(List.of("/proxy", "/second"), read(f.client));
}
}
}
+2
View File
@@ -5,6 +5,8 @@ plugins {
dependencies {
api(libs.findLibrary("slf4j-api").get())
api(libs.findLibrary("adventure-api").get())
api(libs.findLibrary("adventure-minimessage").get())
}
java {
@@ -1,6 +1,7 @@
package dev.strataproxy.api;
import java.util.Objects;
import net.kyori.adventure.text.Component;
/** Allows or denies a connection or login access check. */
public sealed interface AccessDecision permits AccessDecision.Allowed, AccessDecision.Denied {
@@ -9,6 +10,11 @@ public sealed interface AccessDecision permits AccessDecision.Allowed, AccessDec
}
static AccessDecision deny(String reason) {
PlainTextValidation.validateString(reason, false, "reason");
return new Denied(Component.text(reason));
}
static AccessDecision deny(Component reason) {
return new Denied(reason);
}
@@ -16,12 +22,9 @@ public sealed interface AccessDecision permits AccessDecision.Allowed, AccessDec
private static final Allowed INSTANCE = new Allowed();
}
record Denied(String reason) implements AccessDecision {
record Denied(Component reason) implements AccessDecision {
public Denied {
Objects.requireNonNull(reason, "reason");
if (reason.isBlank() || reason.codePointCount(0, reason.length()) > 1024) {
throw new IllegalArgumentException("reason must contain 1 to 1024 characters");
}
}
}
}
@@ -0,0 +1,13 @@
package dev.strataproxy.api;
import java.util.List;
import java.util.concurrent.CompletionStage;
/**
* Produces last-token suggestions asynchronously on bounded plugin workers. The plugin owns prefix matching;
* the proxy validates and bounds results but does not filter them against the player's current token.
*/
@FunctionalInterface
public interface CommandCompleter {
CompletionStage<List<String>> complete(CommandCompletion completion) throws Exception;
}
@@ -0,0 +1,12 @@
package dev.strataproxy.api;
import java.util.Objects;
/** Immutable context for completing raw arguments after a known command root, excluding one separator. */
public record CommandCompletion(PlayerView player, String commandName, String arguments) {
public CommandCompletion {
Objects.requireNonNull(player, "player");
Objects.requireNonNull(commandName, "commandName");
Objects.requireNonNull(arguments, "arguments");
}
}
@@ -2,15 +2,16 @@ package dev.strataproxy.api;
import java.util.Objects;
import java.util.function.Consumer;
import net.kyori.adventure.text.Component;
/** One player command. The raw arguments exclude the leading command name and separator. */
public final class CommandInvocation {
private final PlayerView player;
private final String name;
private final String arguments;
private final Consumer<String> reply;
private final Consumer<Component> reply;
public CommandInvocation(PlayerView player, String name, String arguments, Consumer<String> reply) {
public CommandInvocation(PlayerView player, String name, String arguments, Consumer<Component> reply) {
this.player = Objects.requireNonNull(player, "player");
this.name = Objects.requireNonNull(name, "name");
this.arguments = Objects.requireNonNull(arguments, "arguments");
@@ -21,8 +22,14 @@ public final class CommandInvocation {
public String name() { return name; }
public String arguments() { return arguments; }
/** Queues plain text chat if the session is connected and writable; excess replies may be dropped. */
public void reply(String message) {
/** Queues rich text if the session is connected and writable; excess replies may be dropped. */
public void reply(Component message) {
reply.accept(Objects.requireNonNull(message, "message"));
}
/** Queues plain text, retaining the 1024 Unicode code point convenience limit. */
public void reply(String message) {
PlainTextValidation.validateString(message, true, "message");
reply.accept(Component.text(message));
}
}
@@ -6,5 +6,10 @@ public interface Commands {
* Reserves a root command name. Names are case insensitive and unique across plugins.
* An unregistered command continues to the backend unchanged.
*/
CommandRegistration register(String name, CommandHandler handler);
CommandRegistration register(String name, CommandHandler handler, CommandCompleter completer);
/** Registers a command without argument completion. */
default CommandRegistration register(String name, CommandHandler handler) {
return register(name, handler, null);
}
}
@@ -1,6 +1,7 @@
package dev.strataproxy.api;
import java.util.Objects;
import net.kyori.adventure.text.Component;
/** Decision returned by an initial placement handler. */
public sealed interface PlacementDecision permits PlacementDecision.Select, PlacementDecision.Reject {
@@ -9,6 +10,11 @@ public sealed interface PlacementDecision permits PlacementDecision.Select, Plac
}
static PlacementDecision reject(String reason) {
PlainTextValidation.validateString(reason, false, "reason");
return new Reject(Component.text(reason));
}
static PlacementDecision reject(Component reason) {
return new Reject(reason);
}
@@ -21,12 +27,9 @@ public sealed interface PlacementDecision permits PlacementDecision.Select, Plac
}
}
record Reject(String reason) implements PlacementDecision {
record Reject(Component reason) implements PlacementDecision {
public Reject {
Objects.requireNonNull(reason, "reason");
if (reason.isBlank() || reason.codePointCount(0, reason.length()) > 1024) {
throw new IllegalArgumentException("reason must contain 1 to 1024 characters");
}
}
}
}
@@ -0,0 +1,18 @@
package dev.strataproxy.api;
import java.util.Objects;
/** Shared validation for the plain String convenience overloads. */
final class PlainTextValidation {
private PlainTextValidation() { }
static void validateString(String text, boolean allowEmpty, String name) {
Objects.requireNonNull(text, name);
int length = text.codePointCount(0, text.length());
if ((!allowEmpty && text.isBlank()) || length > 1024) {
throw new IllegalArgumentException(name + (allowEmpty
? " must contain at most 1024 Unicode code points"
: " must contain 1 to 1024 Unicode code points"));
}
}
}
@@ -1,5 +1,7 @@
package dev.strataproxy.api;
import net.kyori.adventure.text.Component;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletionStage;
@@ -21,24 +23,36 @@ public interface Players {
CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName);
/**
* Sends a plain-text message to this exact connection. Text may contain up to 1024 Unicode
* code points, including an empty message. Login, initial Forge negotiation, and transfer
* Sends a rich-text message to this exact connection. Components are bounded and validated
* before serialization. Login, initial Forge negotiation, and transfer
* transitions return NOT_READY. At most 64 messages per connection may be outstanding;
* a full queue or unwritable connection returns BACKPRESSURED without waiting for capacity.
* SENT means the network write completed, not that the client displayed the message.
* Network write failures complete the stage exceptionally; invalid arguments throw immediately.
* Through PluginContext, the result completes off the player's I/O loop.
*/
CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message);
CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, Component message);
/** Sends plain text with a 1024 Unicode code point limit; the empty string is allowed. */
default CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message) {
PlainTextValidation.validateString(message, true, "message");
return sendMessage(identity, Component.text(message));
}
/**
* Closes this exact connection, including an identified player still in admission or placement.
* The nonblank plain-text reason must contain at most 1024 Unicode code points. Its delivery is
* The reason must render as nonblank text. Its delivery is
* best effort, with at most five seconds allowed for the write before closing. DISCONNECTED
* means the connection has been cleaned up and closed, not that the client read the reason.
* Repeated requests while closing join the same operation. Cancelling the returned stage does
* not undo an accepted disconnect. Stale connection identities return NOT_CONNECTED.
* Invalid arguments throw immediately. Through PluginContext, completion occurs off I/O.
*/
CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason);
CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, Component reason);
/** Disconnects with plain text, limited to 1 to 1024 Unicode code points. */
default CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
PlainTextValidation.validateString(reason, false, "reason");
return disconnect(identity, Component.text(reason));
}
}
@@ -9,6 +9,7 @@ import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import net.kyori.adventure.text.Component;
class ApiValueTypesTest {
@Test
@@ -47,14 +48,16 @@ class ApiValueTypesTest {
@Test
void placementRejectionBoundsTheClientVisibleReason() {
assertEquals("Try again later", ((PlacementDecision.Reject) PlacementDecision.reject("Try again later")).reason());
assertEquals(Component.text("Try again later"),
((PlacementDecision.Reject) PlacementDecision.reject("Try again later")).reason());
assertThrows(IllegalArgumentException.class, () -> PlacementDecision.reject(" "));
assertThrows(IllegalArgumentException.class, () -> PlacementDecision.reject("x".repeat(1025)));
}
@Test
void accessDenialBoundsTheClientVisibleReason() {
assertEquals("Blocked", ((AccessDecision.Denied) AccessDecision.deny("Blocked")).reason());
assertEquals(Component.text("Blocked"),
((AccessDecision.Denied) AccessDecision.deny("Blocked")).reason());
assertThrows(IllegalArgumentException.class, () -> AccessDecision.deny(" "));
assertThrows(IllegalArgumentException.class, () -> AccessDecision.deny("x".repeat(1025)));
}