feat: introduce WebSocket support with new endpoints and transaction propagation enhancements

This commit is contained in:
Relism
2026-05-11 16:14:26 +02:00
parent a4a16bdb00
commit ccc5550598
55 changed files with 2705 additions and 752 deletions
@@ -0,0 +1,193 @@
package dev.relism.flash;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketSession;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
class HttpServerWebSocketTest {
private FlashApp app;
private int port;
private final AtomicReference<Integer> closed = new AtomicReference<>();
@BeforeEach
void setUp() throws Exception {
try (ServerSocket s = new ServerSocket(0)) {
port = s.getLocalPort();
}
app = FlashApp.create(FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.build());
app.ws("/chat", new WebSocketHandler() {
@Override
public void onOpen(WebSocketSession session) {}
@Override
public void onMessage(WebSocketSession session, WebSocketFrame frame) {
try {
if (frame.opcode() == WebSocketFrame.OP_TEXT) {
session.sendText(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength());
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
});
app.ws("/close", new WebSocketHandler() {
@Override public void onOpen(WebSocketSession session) {}
@Override public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
@Override public void onClose(WebSocketSession session, int code) { closed.set(code); }
});
app.start();
}
@AfterEach
void tearDown() {
if (app != null) app.stop();
}
private static final int SOCKET_TIMEOUT_MS = 5000;
private static String handshakeKey() {
return Base64.getEncoder().encodeToString("flash-test-key".getBytes(StandardCharsets.UTF_8));
}
private static String readHeaders(InputStream in) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
int b, prev3 = -1, prev2 = -1, prev1 = -1;
while ((b = in.read()) != -1) {
out.write(b);
if (prev3 == '\r' && prev2 == '\n' && prev1 == '\r' && b == '\n') break;
prev3 = prev2; prev2 = prev1; prev1 = b;
}
return out.toString(StandardCharsets.UTF_8);
}
private static byte[] textFrame(String message) {
byte[] payload = message.getBytes(StandardCharsets.UTF_8);
byte[] frame = new byte[payload.length + 6];
frame[0] = (byte) 0x81;
frame[1] = (byte) (0x80 | payload.length);
byte[] mask = {1, 2, 3, 4};
System.arraycopy(mask, 0, frame, 2, 4);
for (int i = 0; i < payload.length; i++) {
frame[6 + i] = (byte) (payload[i] ^ mask[i & 3]);
}
return frame;
}
private static byte[] closeFrame(int code) {
byte[] frame = new byte[8];
frame[0] = (byte) 0x88;
frame[1] = (byte) 0x82;
byte[] mask = {1, 2, 3, 4};
System.arraycopy(mask, 0, frame, 2, 4);
frame[6] = (byte) (((code >> 8) & 0xFF) ^ mask[0]);
frame[7] = (byte) ((code & 0xFF) ^ mask[1]);
return frame;
}
@Test
void websocket_upgrade_returns101AndEchoesText() throws Exception {
try (Socket socket = new Socket("127.0.0.1", port);
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream()) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
String key = handshakeKey();
String req = "GET /chat HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Upgrade: websocket\r\n" +
"Connection: keep-alive, Upgrade\r\n" +
"Sec-WebSocket-Key: " + key + "\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n";
out.write(req.getBytes(StandardCharsets.UTF_8));
out.flush();
String headers = readHeaders(in);
assertTrue(headers.startsWith("HTTP/1.1 101 Switching Protocols"));
assertTrue(headers.contains("Upgrade: websocket"));
assertTrue(headers.contains("Connection: Upgrade"));
assertTrue(headers.contains("Sec-WebSocket-Accept: "));
out.write(textFrame("hello"));
out.flush();
byte[] frame = in.readNBytes(7);
assertEquals((byte) 0x81, frame[0]);
assertEquals((byte) 0x05, frame[1]);
assertEquals("hello", new String(frame, 2, 5, StandardCharsets.UTF_8));
}
}
@Test
void websocket_ping_is_ponged() throws Exception {
try (Socket socket = new Socket("127.0.0.1", port);
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream()) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
out.write(("GET /chat HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Key: " + handshakeKey() + "\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n").getBytes(StandardCharsets.UTF_8));
out.flush();
readHeaders(in);
out.write(new byte[] {(byte) 0x89, (byte) 0x80, 1, 2, 3, 4});
out.flush();
byte[] pong = in.readNBytes(2);
assertEquals((byte) 0x8A, pong[0]);
assertEquals((byte) 0x00, pong[1]);
}
}
@Test
void websocket_close_frame_closesSession() throws Exception {
try (Socket socket = new Socket("127.0.0.1", port);
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream()) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
out.write(("GET /close HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Key: " + handshakeKey() + "\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n").getBytes(StandardCharsets.UTF_8));
out.flush();
readHeaders(in);
out.write(closeFrame(1000));
out.flush();
Thread.sleep(100);
assertEquals(1000, closed.get());
}
}
}
@@ -0,0 +1,47 @@
package dev.relism.flash.extension;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketSession;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.net.ServerSocket;
import static org.junit.jupiter.api.Assertions.*;
class FlashAppWebSocketTest {
private FlashApp app;
private int port;
@BeforeEach
void setUp() throws Exception {
try (ServerSocket s = new ServerSocket(0)) {
port = s.getLocalPort();
}
app = FlashApp.create(FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.build());
}
@AfterEach
void tearDown() {
if (app != null) app.stop();
}
@Test
void ws_registersDirectEndpoint() {
WebSocketHandler handler = new WebSocketHandler() {
public void onOpen(WebSocketSession session) {}
public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
};
assertSame(app, app.ws("/chat", handler));
}
}
@@ -0,0 +1,24 @@
package dev.relism.flash.extension;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PackageScannerTest {
@Test
void scan_separatesHttpHandlersAndWsEndpoints() {
PackageScanner.ScanResult result = PackageScanner.scan("dev.relism.flash.websocket.onlyws");
assertTrue(result.wsEndpoints().stream().anyMatch(c -> c.getSimpleName().equals("OnlyWsEndpoint")));
assertFalse(result.httpHandlers().stream().anyMatch(c -> c.getSimpleName().equals("OnlyWsEndpoint")));
}
@Test
void scan_includesHttpHandlerAndWsEndpointFromTestPackage() {
PackageScanner.ScanResult result = PackageScanner.scan("dev.relism.flash.websocket.scantest");
assertTrue(result.httpHandlers().stream().anyMatch(c -> c.getSimpleName().equals("ScanHttpHandler")));
assertTrue(result.wsEndpoints().stream().anyMatch(c -> c.getSimpleName().equals("ScanWsEndpoint")));
}
}
@@ -0,0 +1,43 @@
package dev.relism.flash.routing;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Request;
import dev.relism.flash.websocket.WebSocketHandler;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AbstractWsRouterTest {
static class DummyWsRouter extends AbstractWsRouter {
WebSocketHandler lastHandler;
HttpMethod lastMethod;
String lastPath;
@Override
public WebSocketHandler route(Request request) { return null; }
@Override
protected AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler) {
this.lastMethod = method;
this.lastPath = path;
this.lastHandler = handler;
return this;
}
}
@Test
void register_sanitizesPathAndStoresHandler() {
DummyWsRouter router = new DummyWsRouter();
WebSocketHandler handler = new WebSocketHandler() {
public void onOpen(dev.relism.flash.websocket.WebSocketSession session) {}
public void onMessage(dev.relism.flash.websocket.WebSocketSession session, dev.relism.flash.websocket.WebSocketFrame frame) {}
};
router.register(HttpMethod.GET, "chat/", handler);
assertEquals(HttpMethod.GET, router.lastMethod);
assertEquals("/chat", router.lastPath);
assertSame(handler, router.lastHandler);
}
}
@@ -0,0 +1,40 @@
package dev.relism.flash.websocket;
import dev.relism.flash.extension.FlashContext;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class WebSocketEndpointTest {
static class DummyEndpoint extends WebSocketEndpoint {
boolean initCalled;
@Override
protected void onInit() {
initCalled = true;
}
@Override
public void onOpen(WebSocketSession session) {}
@Override
public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
}
@Test
void bind_callsOnInit() {
DummyEndpoint endpoint = new DummyEndpoint();
endpoint.bind(new FlashContext());
assertTrue(endpoint.initCalled);
}
@Test
void requireBeforeBind_throws() {
DummyEndpoint endpoint = new DummyEndpoint();
assertThrows(IllegalStateException.class, () -> endpoint.require(String.class));
}
}
@@ -0,0 +1,24 @@
package dev.relism.flash.websocket;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class WebSocketFrameTest {
@Test
void copyPayload_copiesActiveSlice() {
WebSocketFrame frame = new WebSocketFrame();
byte[] buf = "hello".getBytes();
frame.reset(buf, 1, 3, WebSocketFrame.OP_TEXT, true);
byte[] copy = frame.copyPayload();
assertArrayEquals("ell".getBytes(), copy);
assertNotSame(buf, copy);
assertEquals(WebSocketFrame.OP_TEXT, frame.opcode());
assertTrue(frame.isFin());
assertEquals(1, frame.payloadOffset());
assertEquals(3, frame.payloadLength());
}
}
@@ -0,0 +1,43 @@
package dev.relism.flash.websocket;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import static org.junit.jupiter.api.Assertions.*;
class WebSocketSessionFrameTest {
@Test
void readFrame_unmasksMaskedPayload() throws Exception {
byte[] raw = new byte[] {
(byte) 0x81,
(byte) 0x85,
1, 2, 3, 4,
(byte) ('h' ^ 1),
(byte) ('i' ^ 2),
(byte) ('!' ^ 3),
(byte) ('!' ^ 4),
(byte) ('?' ^ 1)
};
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 16);
WebSocketFrame frame = new WebSocketFrame();
assertTrue(session.readFrame(frame));
assertEquals(WebSocketFrame.OP_TEXT, frame.opcode());
assertTrue(frame.isFin());
assertEquals(5, frame.payloadLength());
assertEquals("hi!!?", new String(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength()));
}
@Test
void readFrame_rejectsOversizedPayload() {
byte[] raw = new byte[] {(byte) 0x82, (byte) 0x7E, 0x01, 0x00};
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 8);
WebSocketFrame frame = new WebSocketFrame();
assertThrows(Exception.class, () -> session.readFrame(frame));
}
}
@@ -0,0 +1,39 @@
package dev.relism.flash.websocket;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import static org.junit.jupiter.api.Assertions.*;
class WebSocketSessionTest {
@Test
void close_setsClosedAndWritesFrame() throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), out, 64);
session.close(1000);
assertFalse(session.isOpen());
assertEquals(1000, session.closeCode());
byte[] bytes = out.toByteArray();
assertEquals((byte) 0x88, bytes[0]);
assertEquals((byte) 0x02, bytes[1]);
assertEquals((byte) 0x03, bytes[2]);
assertEquals((byte) 0xE8, bytes[3]);
}
@Test
void closeFromPeer_extractsCloseCode() {
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64);
WebSocketFrame frame = new WebSocketFrame();
frame.reset(new byte[] {(byte) 0x03, (byte) 0xE8}, 0, 2, WebSocketFrame.OP_CLOSE, true);
session.closeFromPeer(frame);
assertFalse(session.isOpen());
assertEquals(1000, session.closeCode());
}
}
@@ -0,0 +1,22 @@
package dev.relism.flash.websocket.onlyws;
import dev.relism.flash.routing.Ws;
import dev.relism.flash.websocket.WebSocketEndpoint;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketSession;
@Ws("/only")
public class OnlyWsEndpoint extends WebSocketEndpoint {
@Override
public void onOpen(WebSocketSession session) {}
@Override
public void onMessage(WebSocketSession session, WebSocketFrame frame) {
try {
session.sendText(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
@@ -0,0 +1,16 @@
package dev.relism.flash.websocket.scantest;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.GET;
@GET("/http")
public class ScanHttpHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return "http";
}
}
@@ -0,0 +1,22 @@
package dev.relism.flash.websocket.scantest;
import dev.relism.flash.routing.Ws;
import dev.relism.flash.websocket.WebSocketEndpoint;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketSession;
@Ws("/ws")
public class ScanWsEndpoint extends WebSocketEndpoint {
@Override
public void onOpen(WebSocketSession session) {}
@Override
public void onMessage(WebSocketSession session, WebSocketFrame frame) {
try {
session.sendText(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}