feat: introduce WebSocket support with new endpoints and transaction propagation enhancements
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
package dev.relism.flash;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.http.HttpStatus;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpStatus;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.routing.AbstractRouter;
|
||||
import dev.relism.flash.routing.AbstractWsRouter;
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -17,73 +22,163 @@ import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread
|
||||
* executor, and the keep-alive accept loop. Routing is delegated to a single
|
||||
* {@link AbstractRouter}.
|
||||
* executor, and the keep-alive accept loop. Routing is delegated to HTTP and WS routers.
|
||||
*
|
||||
* <p>Package-private : use {@link FlashApp} as the single
|
||||
* entry point.
|
||||
* <h3>Allocation model (unchanged)</h3>
|
||||
* <ul>
|
||||
* <li>{@code LONG_BUF} (20 bytes) is the only {@link ThreadLocal} kept here.</li>
|
||||
* <li>WS handshake SHA-1: {@link ThreadLocal}<{@link MessageDigest}> — one per
|
||||
* accept thread (there are now {@code ACCEPT_THREADS} of them, not one).</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
class HttpServer implements ServerHandle {
|
||||
|
||||
private final FlashConfiguration configuration;
|
||||
private final ServerSocket serverSocket;
|
||||
private final AbstractRouter router;
|
||||
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
||||
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
||||
private volatile boolean stopped = false;
|
||||
private final AtomicReference<Thread> acceptThread = new AtomicReference<>();
|
||||
// ── Tuning constants ──────────────────────────────────────────────────────
|
||||
|
||||
private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
/**
|
||||
* Number of platform threads competing on {@code serverSocket.accept()}.
|
||||
* Rule of thumb: number of available CPU cores, capped at 8.
|
||||
* More than this rarely helps — accept is cheap; the bottleneck is usually
|
||||
* the virtual-thread executor dispatching the connection handler.
|
||||
*/
|
||||
private static final int ACCEPT_THREADS = Math.min(Runtime.getRuntime().availableProcessors(), 8);
|
||||
|
||||
/**
|
||||
* TCP listen backlog. The kernel holds up to this many fully-established
|
||||
* (SYN+ACK sent, ACK received) connections waiting for accept().
|
||||
* 4096 is safe on Linux; /proc/sys/net/core/somaxconn must be >= this value,
|
||||
* or the kernel silently caps it. Raise somaxconn if needed:
|
||||
* sysctl -w net.core.somaxconn=4096
|
||||
*/
|
||||
private static final int ACCEPT_BACKLOG = 4096;
|
||||
|
||||
/**
|
||||
* Socket send/receive buffer sizes. Matched to the WS frame read buffer
|
||||
* ({@link FlashConfiguration#getWsFrameBufferSize()}) so the kernel never
|
||||
* needs to fragment a full frame into multiple TCP segments on the receive
|
||||
* side, and never blocks a write waiting for the send buffer to drain.
|
||||
*
|
||||
* Linux default is 87380 bytes (rmem) / 16384 bytes (wmem). We raise both
|
||||
* to 256 KB — a good fit for up to ~250 KB WS frames with no partial reads.
|
||||
*/
|
||||
private static final int SOCKET_BUF_SIZE = 256 * 1024;
|
||||
|
||||
// ── Instance fields ───────────────────────────────────────────────────────
|
||||
|
||||
private final FlashConfiguration configuration;
|
||||
private final ServerSocket serverSocket;
|
||||
private final AbstractRouter router;
|
||||
private final AbstractWsRouter wsRouter;
|
||||
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
||||
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
||||
private volatile boolean stopped = false;
|
||||
|
||||
/** Latch that reaches 0 when all accept threads have exited. */
|
||||
private final CountDownLatch acceptLatch = new CountDownLatch(ACCEPT_THREADS);
|
||||
|
||||
// ── Static byte constants (written once, read-only on hot path) ──────────
|
||||
|
||||
private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
// Per-thread scratch buffers — allocated once per VT, reused for every request.
|
||||
private static final ThreadLocal<byte[]> LONG_BUF = ThreadLocal.withInitial(() -> new byte[20]);
|
||||
private static final ThreadLocal<byte[]> CHUNK_BUF = ThreadLocal.withInitial(() -> new byte[8192]);
|
||||
private static final byte[] WS_HANDSHAKE_PREFIX =
|
||||
("HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Accept: ")
|
||||
.getBytes(StandardCharsets.ISO_8859_1);
|
||||
private static final byte[] WS_HANDSHAKE_SUFFIX =
|
||||
"\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1);
|
||||
private static final byte[] WS_REJECT_400 =
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
.getBytes(StandardCharsets.ISO_8859_1);
|
||||
|
||||
HttpServer(FlashConfiguration configuration, AbstractRouter router) throws IOException {
|
||||
private static final byte[] WS_GUID_BYTES =
|
||||
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.ISO_8859_1);
|
||||
|
||||
private static final ThreadLocal<MessageDigest> SHA1 =
|
||||
ThreadLocal.withInitial(() -> {
|
||||
try { return MessageDigest.getInstance("SHA-1"); }
|
||||
catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); }
|
||||
});
|
||||
|
||||
private static final ThreadLocal<byte[]> LONG_BUF = ThreadLocal.withInitial(() -> new byte[20]);
|
||||
|
||||
private static final int SHA1_LEN = 20;
|
||||
private static final int WS_ACCEPT_LEN = 28;
|
||||
|
||||
// ── Constructor ───────────────────────────────────────────────────────────
|
||||
|
||||
HttpServer(FlashConfiguration configuration, AbstractRouter router, AbstractWsRouter wsRouter) throws IOException {
|
||||
this.configuration = configuration;
|
||||
this.serverSocket = new ServerSocket(configuration.getPort());
|
||||
this.router = router;
|
||||
this.wsRouter = wsRouter;
|
||||
|
||||
// Explicit bind with raised backlog.
|
||||
// setReuseAddress(true) must be called BEFORE bind().
|
||||
this.serverSocket = new ServerSocket();
|
||||
this.serverSocket.setReuseAddress(true);
|
||||
this.serverSocket.setReceiveBufferSize(SOCKET_BUF_SIZE);
|
||||
this.serverSocket.bind(new InetSocketAddress(configuration.getPort()), ACCEPT_BACKLOG);
|
||||
|
||||
log.info("HttpServer bound on port {} (backlog={}, acceptThreads={})",
|
||||
configuration.getPort(), ACCEPT_BACKLOG, ACCEPT_THREADS);
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
acceptThread.set(Thread.ofPlatform().name("flash-accept-loop").daemon(false).start(this::run));
|
||||
for (int i = 0; i < ACCEPT_THREADS; i++) {
|
||||
final int idx = i;
|
||||
Thread.ofPlatform()
|
||||
.name("flash-accept-" + idx)
|
||||
.daemon(false)
|
||||
.start(this::acceptLoop);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startAndBlock() {
|
||||
start();
|
||||
try { acceptThread.get().join(); }
|
||||
try { acceptLatch.await(); }
|
||||
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||
}
|
||||
|
||||
private void run() {
|
||||
while (!stopped) {
|
||||
try {
|
||||
process(serverSocket.accept());
|
||||
} catch (IOException e) {
|
||||
if (!stopped) log.error("Accept loop error", e);
|
||||
/**
|
||||
* Single accept loop body — runs on each of the {@code ACCEPT_THREADS}
|
||||
* platform threads. All threads block on the same {@link ServerSocket};
|
||||
* the JVM ensures only one wakes per incoming connection (no thundering herd).
|
||||
*/
|
||||
private void acceptLoop() {
|
||||
try {
|
||||
while (!stopped) {
|
||||
try {
|
||||
process(serverSocket.accept());
|
||||
} catch (IOException e) {
|
||||
if (!stopped) log.error("Accept error", e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
acceptLatch.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,24 +199,62 @@ class HttpServer implements ServerHandle {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Hot-path ─────────────────────────────────────────────────────────────
|
||||
// ── Hot-path ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void process(Socket socket) {
|
||||
try {
|
||||
executorService.submit(() -> {
|
||||
activeSockets.add(socket);
|
||||
try (socket;
|
||||
InputStream in = socket.getInputStream();
|
||||
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
|
||||
InputStream in = socket.getInputStream();
|
||||
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
|
||||
|
||||
// TCP_NODELAY: disable Nagle's algorithm.
|
||||
// Small WS frames (< MSS) are sent immediately rather than
|
||||
// waiting up to 200 ms for more data to coalesce. Latency
|
||||
// drops significantly at the cost of slightly more TCP segments
|
||||
// under sustained bulk transfer — acceptable for interactive WS.
|
||||
socket.setTcpNoDelay(true);
|
||||
socket.setSendBufferSize(SOCKET_BUF_SIZE);
|
||||
|
||||
// rawOut is the unbuffered socket stream — passed to WebSocketSession
|
||||
// directly. WS writes are already bulk (header + payload in two calls);
|
||||
// with TCP_NODELAY the kernel ships them without Nagle delay, so no
|
||||
// userspace buffer is needed and no flush() is required per frame.
|
||||
// HTTP responses continue to use the BufferedOutputStream (out) because
|
||||
// writeResponse() does many small individual writes that benefit from
|
||||
// userspace coalescing before a single syscall.
|
||||
OutputStream rawOut = socket.getOutputStream();
|
||||
|
||||
RequestParser parser = new RequestParser(
|
||||
configuration.getMaxHeaderBufferSize(),
|
||||
(InetSocketAddress) socket.getRemoteSocketAddress());
|
||||
|
||||
while (!stopped) {
|
||||
Request request = parser.parse(in);
|
||||
if (request == null) break;
|
||||
|
||||
boolean keepAlive = isKeepAlive(request);
|
||||
Response response = new Response(200, ContentType.TEXT_PLAIN);
|
||||
if (request.method() == HttpMethod.GET && isWebSocketUpgrade(request)) {
|
||||
WebSocketHandler wsHandler = wsRouter.route(request);
|
||||
if (wsHandler == null) {
|
||||
out.write(WS_REJECT_400);
|
||||
out.flush();
|
||||
break;
|
||||
}
|
||||
// Flush buffered HTTP bytes (the 101 response) before WebSocketSession
|
||||
// takes over rawOut — otherwise the handshake reply stays stuck in
|
||||
// the BufferedOutputStream buffer and the client never sees it.
|
||||
performHandshake(out, request);
|
||||
out.flush();
|
||||
request.drain();
|
||||
WebSocketSession session = new WebSocketSession(
|
||||
in, rawOut, configuration.getWsFrameBufferSize());
|
||||
runWsLoop(session, wsHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
boolean keepAlive = isKeepAlive(request);
|
||||
Response response = new Response(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
RequestHandler handler = router.route(request);
|
||||
if (handler == null) handler = router.getNotFoundHandler();
|
||||
@@ -140,6 +273,7 @@ class HttpServer implements ServerHandle {
|
||||
request.drain();
|
||||
if (!keepAlive) break;
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
if (!stopped) {
|
||||
if (e instanceof java.net.SocketException)
|
||||
@@ -152,18 +286,108 @@ class HttpServer implements ServerHandle {
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
// Executor already shut down — close the socket so the client isn't left hanging.
|
||||
try { socket.close(); } catch (IOException e) { log.debug("Error closing socket on shutdown", e); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket upgrade detection (zero-alloc) ──────────────────────────────
|
||||
|
||||
private static boolean isWebSocketUpgrade(Request request) {
|
||||
ByteView upgrade = request.getRequestLine().getHeaders().view("Upgrade");
|
||||
if (upgrade == null) return false;
|
||||
if (!tokenEqualsIgnoreCase(upgrade, 0, upgrade.length(), "websocket")) return false;
|
||||
return connectionContainsUpgrade(request);
|
||||
}
|
||||
|
||||
private static boolean connectionContainsUpgrade(Request request) {
|
||||
ByteView conn = request.getRequestLine().getHeaders().view("Connection");
|
||||
if (conn == null) return false;
|
||||
int len = conn.length(), i = 0;
|
||||
while (i < len) {
|
||||
while (i < len && conn.byteAt(i) == ' ') i++;
|
||||
int start = i;
|
||||
while (i < len && conn.byteAt(i) != ',') i++;
|
||||
if (tokenEqualsIgnoreCase(conn, start, i, "upgrade")) return true;
|
||||
i++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean tokenEqualsIgnoreCase(ByteView view, int start, int end, String token) {
|
||||
int tlen = token.length();
|
||||
int wlen = end - start;
|
||||
while (wlen > 0 && view.byteAt(start + wlen - 1) == ' ') wlen--;
|
||||
if (wlen != tlen) return false;
|
||||
for (int i = 0; i < tlen; i++) {
|
||||
byte b = view.byteAt(start + i);
|
||||
if (b >= 'A' && b <= 'Z') b += 32;
|
||||
if (b != (byte) token.charAt(i)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── WebSocket handshake ───────────────────────────────────────────────────
|
||||
|
||||
private void performHandshake(OutputStream out, Request request) throws IOException {
|
||||
ByteView keyView = request.getRequestLine().getHeaders().view("Sec-WebSocket-Key");
|
||||
if (keyView == null) throw new IOException("Missing Sec-WebSocket-Key header");
|
||||
|
||||
MessageDigest sha1 = SHA1.get();
|
||||
sha1.reset();
|
||||
for (int i = 0, len = keyView.length(); i < len; i++) sha1.update(keyView.byteAt(i));
|
||||
sha1.update(WS_GUID_BYTES);
|
||||
|
||||
byte[] accept = Base64.getEncoder().encode(sha1.digest());
|
||||
|
||||
out.write(WS_HANDSHAKE_PREFIX);
|
||||
out.write(accept);
|
||||
out.write(WS_HANDSHAKE_SUFFIX);
|
||||
out.flush();
|
||||
}
|
||||
|
||||
// ── WebSocket session loop ────────────────────────────────────────────────
|
||||
|
||||
private void runWsLoop(WebSocketSession session, WebSocketHandler handler) {
|
||||
handler.onOpen(session);
|
||||
WebSocketFrame frame = new WebSocketFrame();
|
||||
try {
|
||||
while (session.isOpen()) {
|
||||
if (!session.readFrame(frame)) break;
|
||||
switch (frame.opcode()) {
|
||||
case WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY
|
||||
-> handler.onMessage(session, frame);
|
||||
case WebSocketFrame.OP_CLOSE
|
||||
-> session.closeFromPeer(frame);
|
||||
case WebSocketFrame.OP_PING
|
||||
-> session.sendPong(frame);
|
||||
case WebSocketFrame.OP_PONG -> { /* heartbeat ack, no-op */ }
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
handler.onError(session, e);
|
||||
} finally {
|
||||
handler.onClose(session, session.closeCode());
|
||||
session.forceClose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP keep-alive detection ─────────────────────────────────────────────
|
||||
|
||||
private static boolean isKeepAlive(Request request) {
|
||||
if (request.headerEquals("Connection", "close")) return false;
|
||||
ByteView protocol = request.getRequestLine().getProtocol();
|
||||
return protocol.length() == 8 && protocol.byteAt(7) == '1'
|
||||
|| request.headerEquals("Connection", "keep-alive");
|
||||
int plen = protocol.length();
|
||||
if (plen == 8) {
|
||||
byte minor = protocol.byteAt(7);
|
||||
if (minor == '1') return true;
|
||||
if (minor == '0') return request.headerEquals("Connection", "keep-alive");
|
||||
}
|
||||
log.debug("Unrecognised protocol '{}', treating as close", protocol);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Response serialisation ────────────────────────────────────────────────
|
||||
|
||||
private static void writeResponse(OutputStream out, Response response, boolean keepAlive) throws IOException {
|
||||
out.write(HTTP_1_1);
|
||||
byte[] statusBytes = response.getStatusBytes();
|
||||
@@ -223,7 +447,7 @@ class HttpServer implements ServerHandle {
|
||||
}
|
||||
|
||||
private static void writeChunked(OutputStream out, InputStream stream) throws IOException {
|
||||
byte[] buf = CHUNK_BUF.get();
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = stream.read(buf)) > 0) {
|
||||
writeHex(out, n);
|
||||
@@ -235,13 +459,16 @@ class HttpServer implements ServerHandle {
|
||||
}
|
||||
|
||||
private static void writeHex(OutputStream out, int value) throws IOException {
|
||||
int shift = 28;
|
||||
int shift = 28;
|
||||
boolean leading = true;
|
||||
while (shift >= 0) {
|
||||
int digit = (value >>> shift) & 0xF;
|
||||
if (digit != 0 || !leading) { leading = false; out.write(digit < 10 ? '0' + digit : 'a' + digit - 10); }
|
||||
if (digit != 0 || !leading) {
|
||||
leading = false;
|
||||
out.write(digit < 10 ? '0' + digit : 'a' + digit - 10);
|
||||
}
|
||||
shift -= 4;
|
||||
}
|
||||
if (leading) out.write('0');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,20 +14,39 @@ import java.net.InetSocketAddress;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* One instance per connection, the buffer is allocated once and reused across keep-alive
|
||||
* requests. Grows on demand (doubling, up to {@code maxHeaderBufferSize}). Zero String
|
||||
* allocations during parsing; paths, headers and protocol are exposed as {@link dev.relism.fpr.core.ByteView} slices.
|
||||
* One instance per connection. The buffer is allocated once and reused across
|
||||
* keep-alive requests. Grows on demand (doubling, up to {@code maxHeaderBufferSize}).
|
||||
*
|
||||
* <h3>Zero-allocation design</h3>
|
||||
* <ul>
|
||||
* <li>No {@link String} allocations during parsing: paths, headers and
|
||||
* protocol are exposed as {@link dev.relism.fpr.core.ByteView} slices
|
||||
* into the shared buffer.</li>
|
||||
* <li>{@link #headerMap} is reset in-place per request — single allocation
|
||||
* for the lifetime of the connection.</li>
|
||||
* <li>Pipelining / keep-alive leftover bytes are tracked via {@code bufBase}
|
||||
* and {@code bufLen} — no copy between requests on the common path.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>State invariant</h3>
|
||||
* {@code bufBase} and {@code bufLen} always reflect unconsumed bytes that belong
|
||||
* to the <em>next</em> request. They are snapshotted at the top of {@link #parse}
|
||||
* and reset to {@code 0/0} before any work begins, so an exception thrown mid-parse
|
||||
* leaves the fields clean rather than pointing at stale data from a previous request.
|
||||
*/
|
||||
@Slf4j
|
||||
public class RequestParser {
|
||||
private static final int INITIAL_BUFFER_SIZE = 8192;
|
||||
|
||||
private final int maxHeaderBufferSize;
|
||||
private final InetSocketAddress remoteAddress; // set once per connection, never changes
|
||||
private final HeaderMap headerMap = new HeaderMap();
|
||||
private final int maxHeaderBufferSize;
|
||||
private final InetSocketAddress remoteAddress;
|
||||
private final HeaderMap headerMap = new HeaderMap();
|
||||
private byte[] buffer;
|
||||
private int bufBase = 0; // absolute start of valid data in buffer
|
||||
private int bufLen = 0; // number of valid bytes from bufBase
|
||||
|
||||
// Unconsumed bytes belonging to the NEXT request.
|
||||
// Reset to 0/0 at the start of every parse() call — see invariant above.
|
||||
private int bufBase = 0;
|
||||
private int bufLen = 0;
|
||||
|
||||
public RequestParser() { this(64 * 1024, null); }
|
||||
public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null); }
|
||||
@@ -37,9 +56,21 @@ public class RequestParser {
|
||||
this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the next HTTP request from {@code in}.
|
||||
*
|
||||
* <p><b>Exception safety:</b> {@code bufBase} and {@code bufLen} are reset
|
||||
* to {@code 0} before any parsing work begins. If an exception is thrown,
|
||||
* the connection will be closed by the caller, so stale leftover state is
|
||||
* never a problem — but the reset ensures correctness in test scenarios where
|
||||
* the same parser instance is reused after an error.
|
||||
*
|
||||
* @return the parsed {@link Request}, or {@code null} on clean EOF.
|
||||
* @throws IOException on malformed headers or I/O failure.
|
||||
*/
|
||||
public Request parse(InputStream in) throws IOException {
|
||||
// Take ownership of any leftover bytes from the previous request, then reset so
|
||||
// early-returns leave the fields in a clean state.
|
||||
// Snapshot leftover bytes from the previous request, then reset immediately.
|
||||
// Any exception thrown below leaves bufBase/bufLen at 0 — safe state.
|
||||
int base = bufBase;
|
||||
int totalRead = bufLen;
|
||||
bufBase = 0;
|
||||
@@ -49,8 +80,8 @@ public class RequestParser {
|
||||
while (headerEndIdx == -1) {
|
||||
if (base + totalRead == buffer.length) {
|
||||
if (base > 0) {
|
||||
// Compact: slide valid data to position 0 — rare path (~every N requests
|
||||
// where N = bufferSize / avgRequestSize rather than every request).
|
||||
// Compact: slide valid data to position 0.
|
||||
// Rare path (~every N requests where N ≈ bufferSize / avgRequestSize).
|
||||
System.arraycopy(buffer, base, buffer, 0, totalRead);
|
||||
base = 0;
|
||||
} else if (buffer.length >= maxHeaderBufferSize) {
|
||||
@@ -70,6 +101,8 @@ public class RequestParser {
|
||||
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
|
||||
}
|
||||
|
||||
// ── Request line ─────────────────────────────────────────────────────
|
||||
|
||||
int methodEnd = find(buffer, base, headerEndIdx, (byte) ' ');
|
||||
if (methodEnd == -1) throw new IOException("Invalid request line (method)");
|
||||
|
||||
@@ -81,20 +114,23 @@ public class RequestParser {
|
||||
if (pathEnd == -1) throw new IOException("Invalid request line (path)");
|
||||
|
||||
int queryMark = find(buffer, pathStart, pathEnd, (byte) '?');
|
||||
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart,
|
||||
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart,
|
||||
queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart);
|
||||
FastPathViews.RequestByteView queryView = queryMark != -1
|
||||
? new FastPathViews.RequestByteView(buffer, queryMark + 1, pathEnd - queryMark - 1)
|
||||
: null;
|
||||
|
||||
int protocolStart = pathEnd + 1;
|
||||
int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r');
|
||||
int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r');
|
||||
if (protocolEnd == -1) throw new IOException("Invalid request line (protocol)");
|
||||
|
||||
FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
|
||||
FastPathViews.RequestByteView protocolView =
|
||||
new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
|
||||
|
||||
// ── Headers ──────────────────────────────────────────────────────────
|
||||
|
||||
int sectionStart = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
|
||||
int current = sectionStart;
|
||||
int current = sectionStart;
|
||||
long contentLength = 0;
|
||||
boolean isChunked = false;
|
||||
|
||||
@@ -118,35 +154,40 @@ public class RequestParser {
|
||||
|
||||
headerMap.reset(buffer, sectionStart, headerEndIdx);
|
||||
|
||||
// ── Body / pipelining accounting ─────────────────────────────────────
|
||||
|
||||
int bodyStart = headerEndIdx + 4;
|
||||
int preBufLen = (base + totalRead) - bodyStart;
|
||||
|
||||
// Any bytes read beyond this request's body belong to the next request.
|
||||
// Store their absolute position in the buffer — no copy needed; the next parse()
|
||||
// call will read directly from bufBase without touching the data.
|
||||
// Bytes read beyond this request's body belong to the next request.
|
||||
// Store their absolute position — no copy needed; the next parse() call
|
||||
// reads directly from bufBase without touching the data.
|
||||
if (!isChunked && contentLength == 0 && preBufLen > 0) {
|
||||
bufBase = bodyStart;
|
||||
bufLen = preBufLen;
|
||||
bufBase = bodyStart;
|
||||
bufLen = preBufLen;
|
||||
preBufLen = 0;
|
||||
} else if (!isChunked && contentLength > 0 && preBufLen > contentLength) {
|
||||
bufBase = bodyStart + (int) contentLength;
|
||||
bufLen = preBufLen - (int) contentLength;
|
||||
bufBase = bodyStart + (int) contentLength;
|
||||
bufLen = preBufLen - (int) contentLength;
|
||||
preBufLen = (int) contentLength;
|
||||
}
|
||||
|
||||
RequestLine requestLine = new RequestLine(method, pathView, queryView, protocolView, headerMap);
|
||||
|
||||
if (isChunked) {
|
||||
return Request.forParsed(requestLine, new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0, remoteAddress);
|
||||
return Request.forParsed(requestLine,
|
||||
new ChunkedInputStream(in, buffer, bodyStart, preBufLen),
|
||||
-1L, null, 0, 0, remoteAddress);
|
||||
}
|
||||
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress);
|
||||
}
|
||||
|
||||
// ── Buffer scanning utilities (hot path — keep branch-free where possible) ──
|
||||
|
||||
private static int findEndOfHeader(byte[] buf, int from, int len) {
|
||||
for (int i = from; i <= len - 4; i++) {
|
||||
if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n') {
|
||||
if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n')
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -177,4 +218,4 @@ public class RequestParser {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package dev.relism.flash;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.routing.AbstractRouter;
|
||||
import dev.relism.flash.routing.AbstractWsRouter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -26,7 +27,9 @@ public interface ServerHandle {
|
||||
/** Gracefully stops the server, draining active connections. */
|
||||
CompletableFuture<Void> stop();
|
||||
|
||||
static ServerHandle create(FlashConfiguration config, AbstractRouter router) throws IOException {
|
||||
return new HttpServer(config, router);
|
||||
static ServerHandle create(FlashConfiguration config,
|
||||
AbstractRouter httpRouter,
|
||||
AbstractWsRouter wsRouter) throws IOException {
|
||||
return new HttpServer(config, httpRouter, wsRouter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,14 @@ import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.SimpleHandler;
|
||||
import dev.relism.flash.routing.AbstractRouter;
|
||||
import dev.relism.flash.routing.AbstractWsRouter;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -52,16 +58,20 @@ import java.util.function.Consumer;
|
||||
public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
|
||||
private final AbstractRouter router = new FastPathRouterImpl();
|
||||
private final AbstractWsRouter wsRouter = new FastPathWsRouterImpl();
|
||||
private final ServerHandle server;
|
||||
private final FlashContext ctx = new FlashContext();
|
||||
private final List<FlashExtension> extensions = new ArrayList<>();
|
||||
private final List<Middleware> globalMiddlewares = new ArrayList<>();
|
||||
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
|
||||
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
|
||||
private int port;
|
||||
|
||||
private record WsRouteDefinition(String path, WebSocketEndpoint endpoint) {}
|
||||
|
||||
private FlashApp(FlashConfiguration config) {
|
||||
try {
|
||||
this.server = ServerHandle.create(config, router);
|
||||
this.server = ServerHandle.create(config, router, wsRouter);
|
||||
this.port = config.getPort();
|
||||
}
|
||||
catch (IOException e) { throw new InitializationException("Failed to bind on port " + config.getPort(), e); }
|
||||
@@ -83,6 +93,26 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
FlashScope scope = new FlashScope(namespace, ctx);
|
||||
configure.accept(scope);
|
||||
deferredRoutes.addAll(scope.routes());
|
||||
deferredWsRoutes.addAll(scope.wsRoutes().stream()
|
||||
.map(r -> new WsRouteDefinition(r.path(), r.endpoint()))
|
||||
.toList());
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a WebSocket endpoint at {@code path}.
|
||||
* Runs on the same virtual-thread model as HTTP handlers.
|
||||
* A path may have both an HTTP handler and a WS endpoint simultaneously.
|
||||
*/
|
||||
public FlashApp ws(String path, WebSocketHandler handler) {
|
||||
WebSocketEndpoint endpoint = handler instanceof WebSocketEndpoint e ? e
|
||||
: new WebSocketEndpoint() {
|
||||
@Override public void onOpen(WebSocketSession s) { handler.onOpen(s); }
|
||||
@Override public void onMessage(WebSocketSession s, WebSocketFrame f) { handler.onMessage(s, f); }
|
||||
@Override public void onClose(WebSocketSession s, int c) { handler.onClose(s, c); }
|
||||
@Override public void onError(WebSocketSession s, Throwable t) { handler.onError(s, t); }
|
||||
};
|
||||
deferredWsRoutes.add(new WsRouteDefinition(path, endpoint));
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -152,6 +182,11 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
!(handler instanceof SimpleHandler), ctx, "/"));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addWsRoute(String path, WebSocketEndpoint endpoint) {
|
||||
deferredWsRoutes.add(new WsRouteDefinition(path, endpoint));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addMiddleware(Middleware mw) { globalMiddlewares.add(mw); }
|
||||
|
||||
@@ -163,6 +198,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
ctx.resolveAll();
|
||||
extensions.forEach(e -> e.routes(this, ctx));
|
||||
compile();
|
||||
compileWs();
|
||||
}
|
||||
|
||||
// ── Compilation ──────────────────────────────────────────────────────────
|
||||
@@ -187,6 +223,14 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
}
|
||||
}
|
||||
|
||||
private void compileWs() {
|
||||
for (WsRouteDefinition def : deferredWsRoutes) {
|
||||
def.endpoint().bind(ctx);
|
||||
emitWsEvent(def.path(), def.endpoint());
|
||||
wsRouter.register(HttpMethod.GET, def.path(), def.endpoint());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void emitEvent(RouteDefinition def, Middleware[] chain) {
|
||||
List<RouteListener> listeners = def.ctx().routeListeners();
|
||||
@@ -198,6 +242,14 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
listeners.forEach(l -> l.onRoute(event));
|
||||
}
|
||||
|
||||
private void emitWsEvent(String path, WebSocketEndpoint endpoint) {
|
||||
List<RouteListener> listeners = ctx.routeListeners();
|
||||
if (listeners.isEmpty()) return;
|
||||
RouteEvent event = new RouteEvent(HttpMethod.GET, path, "/", "FlashApp",
|
||||
endpoint.getClass(), List.of());
|
||||
listeners.forEach(l -> l.onRoute(event));
|
||||
}
|
||||
|
||||
private static Middleware[] concat(List<Middleware> global, List<Middleware> scope,
|
||||
List<Middleware> injected, List<Middleware> explicit) {
|
||||
int total = global.size() + scope.size() + injected.size() + explicit.size();
|
||||
|
||||
@@ -28,4 +28,8 @@ public class FlashConfiguration {
|
||||
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
|
||||
@Builder.Default
|
||||
int maxHeaderBufferSize = 64 * 1024;
|
||||
|
||||
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
|
||||
@Builder.Default
|
||||
int wsFrameBufferSize = 64 * 1024;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import dev.relism.flash.exceptions.InitializationException;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.SimpleHandler;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.flash.routing.Route;
|
||||
import dev.relism.flash.routing.Routes;
|
||||
import dev.relism.flash.routing.Ws;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -68,10 +70,15 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
|
||||
* class fails to load or instantiate
|
||||
*/
|
||||
public final SELF scan(String packageName) {
|
||||
PackageScanner.findHandlers(packageName).forEach(cls -> {
|
||||
PackageScanner.ScanResult found = PackageScanner.scan(packageName);
|
||||
found.httpHandlers().forEach(cls -> {
|
||||
Route ann = Routes.of(cls);
|
||||
addRoute(ann.method(), ann.path(), instantiate(cls), List.of());
|
||||
});
|
||||
found.wsEndpoints().forEach(cls -> {
|
||||
Ws ann = cls.getAnnotation(Ws.class);
|
||||
addWsRoute(ann.value(), instantiateWs(cls));
|
||||
});
|
||||
return (SELF) this;
|
||||
}
|
||||
|
||||
@@ -87,6 +94,8 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
|
||||
protected abstract void addRoute(HttpMethod method, String path,
|
||||
RequestHandler handler, List<Middleware> mw);
|
||||
|
||||
protected abstract void addWsRoute(String path, WebSocketEndpoint endpoint);
|
||||
|
||||
/** Registers a middleware in this registrar's own scope (global or scope-level). */
|
||||
protected abstract void addMiddleware(Middleware mw);
|
||||
|
||||
@@ -103,4 +112,13 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
|
||||
" — ensure it has a public no-arg constructor", e);
|
||||
}
|
||||
}
|
||||
|
||||
protected static WebSocketEndpoint instantiateWs(Class<?> cls) {
|
||||
try { return (WebSocketEndpoint) cls.getDeclaredConstructor().newInstance(); }
|
||||
catch (Exception e) {
|
||||
throw new InitializationException(
|
||||
"Failed to instantiate WS endpoint " + cls.getName() +
|
||||
" — ensure public no-arg constructor", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.SimpleHandler;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.flash.routing.PathUtils;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -29,6 +30,9 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
|
||||
private final FlashContext ctx;
|
||||
private final List<Middleware> scopeMiddlewares = new ArrayList<>();
|
||||
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
|
||||
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
|
||||
|
||||
record WsRouteDefinition(String path, WebSocketEndpoint endpoint) {}
|
||||
|
||||
FlashScope(String namespace, FlashContext parentCtx) {
|
||||
this.namespace = PathUtils.sanitize(namespace);
|
||||
@@ -47,12 +51,18 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
|
||||
!(handler instanceof SimpleHandler), ctx, namespace));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addWsRoute(String path, WebSocketEndpoint endpoint) {
|
||||
deferredWsRoutes.add(new WsRouteDefinition(ns(path), endpoint));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addMiddleware(Middleware mw) { scopeMiddlewares.add(mw); }
|
||||
|
||||
// ── Internal (called by FlashApp.mount) ───────────────────────────────────
|
||||
|
||||
List<RouteDefinition> routes() { return deferredRoutes; }
|
||||
List<WsRouteDefinition> wsRoutes() { return deferredWsRoutes; }
|
||||
|
||||
private String ns(String path) { return PathUtils.join(namespace, path); }
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package dev.relism.flash.extension;
|
||||
|
||||
import dev.relism.flash.exceptions.InitializationException;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.routing.Ws;
|
||||
import dev.relism.flash.routing.Routes;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Enumeration;
|
||||
@@ -26,6 +29,8 @@ final class PackageScanner {
|
||||
|
||||
private PackageScanner() {}
|
||||
|
||||
record ScanResult(List<Class<?>> httpHandlers, List<Class<?>> wsEndpoints) {}
|
||||
|
||||
/**
|
||||
* Handlers in {@code packageName} that have {@link Routes#of(Class) resolvable} route metadata.
|
||||
*
|
||||
@@ -33,12 +38,17 @@ final class PackageScanner {
|
||||
* handler class fails to load
|
||||
*/
|
||||
static List<Class<?>> findHandlers(String packageName) {
|
||||
return scan(packageName).httpHandlers();
|
||||
}
|
||||
|
||||
static ScanResult scan(String packageName) {
|
||||
if (packageName == null || packageName.isBlank())
|
||||
throw new InitializationException("scan() called with null or blank package name");
|
||||
|
||||
String resourcePath = packageName.replace('.', '/');
|
||||
ClassLoader cl = Thread.currentThread().getContextClassLoader();
|
||||
List<Class<?>> result = new ArrayList<>();
|
||||
List<Class<?>> http = new ArrayList<>();
|
||||
List<Class<?>> ws = new ArrayList<>();
|
||||
List<String> errors = new ArrayList<>();
|
||||
boolean packageFound = false;
|
||||
|
||||
@@ -49,17 +59,17 @@ final class PackageScanner {
|
||||
URL url = resources.nextElement();
|
||||
String protocol = url.getProtocol();
|
||||
if ("file".equals(protocol)) {
|
||||
scanDirectory(new File(url.toURI()), packageName, cl, result, errors);
|
||||
scanDirectory(new File(url.toURI()), packageName, cl, http, ws, errors);
|
||||
} else if ("jar".equals(protocol)) {
|
||||
String jarPath = url.getPath();
|
||||
String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!'));
|
||||
try (JarFile jar = new JarFile(filePart)) {
|
||||
scanJar(jar, resourcePath, packageName, cl, result, errors);
|
||||
scanJar(jar, resourcePath, packageName, cl, http, ws, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (InitializationException e) {
|
||||
throw e; // re-throw our own exceptions
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new InitializationException("Failed to scan package: " + packageName, e);
|
||||
}
|
||||
@@ -74,38 +84,38 @@ final class PackageScanner {
|
||||
"scan(\"" + packageName + "\") — failed to load " + errors.size() + " handler(s):\n • " +
|
||||
String.join("\n • ", errors));
|
||||
|
||||
if (result.isEmpty())
|
||||
if (http.isEmpty() && ws.isEmpty())
|
||||
throw new InitializationException(
|
||||
"scan(\"" + packageName + "\") — no routable handlers found. " +
|
||||
"Ensure classes extend RequestHandler, declare @Route or @GET/@POST/…, are not abstract, " +
|
||||
"and have a public no-arg constructor.");
|
||||
"Ensure classes extend RequestHandler or WebSocketEndpoint, declare the right route annotation, " +
|
||||
"are not abstract, and have a public no-arg constructor.");
|
||||
|
||||
return result;
|
||||
return new ScanResult(List.copyOf(http), List.copyOf(ws));
|
||||
}
|
||||
|
||||
private static void scanDirectory(File dir, String packageName, ClassLoader cl,
|
||||
List<Class<?>> result, List<String> errors) {
|
||||
List<Class<?>> http, List<Class<?>> ws, List<String> errors) {
|
||||
File[] files = dir.listFiles();
|
||||
if (files == null) return;
|
||||
for (File file : files) {
|
||||
if (file.isDirectory()) {
|
||||
scanDirectory(file, packageName + '.' + file.getName(), cl, result, errors);
|
||||
scanDirectory(file, packageName + '.' + file.getName(), cl, http, ws, errors);
|
||||
} else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) {
|
||||
String className = packageName + '.' + file.getName().replace(".class", "");
|
||||
tryLoad(className, cl, result, errors);
|
||||
tryLoad(className, cl, http, ws, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void scanJar(JarFile jar, String resourcePath, String packageName,
|
||||
ClassLoader cl, List<Class<?>> result, List<String> errors) {
|
||||
ClassLoader cl, List<Class<?>> http, List<Class<?>> ws, List<String> errors) {
|
||||
String prefix = resourcePath + "/";
|
||||
Enumeration<JarEntry> entries = jar.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
String name = entries.nextElement().getName();
|
||||
if (name.startsWith(prefix) && name.endsWith(".class") && !isAnonymous(name)) {
|
||||
String className = name.replace('/', '.').replace(".class", "");
|
||||
tryLoad(className, cl, result, errors);
|
||||
tryLoad(className, cl, http, ws, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,22 +135,25 @@ final class PackageScanner {
|
||||
}
|
||||
|
||||
private static void tryLoad(String className, ClassLoader cl,
|
||||
List<Class<?>> result, List<String> errors) {
|
||||
List<Class<?>> http, List<Class<?>> ws, List<String> errors) {
|
||||
try {
|
||||
Class<?> cls = cl.loadClass(className);
|
||||
if (!RequestHandler.class.isAssignableFrom(cls)) return;
|
||||
if (java.lang.reflect.Modifier.isAbstract(cls.getModifiers())) return;
|
||||
if (Routes.of(cls) == null) return;
|
||||
if (Modifier.isAbstract(cls.getModifiers())) return;
|
||||
|
||||
// Verify no-arg constructor exists — fail-fast if missing
|
||||
try {
|
||||
cls.getDeclaredConstructor();
|
||||
} catch (NoSuchMethodException e) {
|
||||
errors.add(className + " — missing public no-arg constructor");
|
||||
if (RequestHandler.class.isAssignableFrom(cls)
|
||||
&& Routes.of(cls) != null
|
||||
&& !cls.isAnnotationPresent(Ws.class)) {
|
||||
assertNoArgConstructor(cls, errors);
|
||||
http.add(cls);
|
||||
return;
|
||||
}
|
||||
|
||||
result.add(cls);
|
||||
if (WebSocketEndpoint.class.isAssignableFrom(cls)
|
||||
&& cls.isAnnotationPresent(Ws.class)) {
|
||||
assertNoArgConstructor(cls, errors);
|
||||
ws.add(cls);
|
||||
return;
|
||||
}
|
||||
} catch (ClassNotFoundException e) {
|
||||
errors.add(className + " — class not found: " + e.getMessage());
|
||||
} catch (NoClassDefFoundError e) {
|
||||
@@ -149,4 +162,9 @@ final class PackageScanner {
|
||||
errors.add(className + " — linkage error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertNoArgConstructor(Class<?> cls, List<String> errors) {
|
||||
try { cls.getDeclaredConstructor(); }
|
||||
catch (NoSuchMethodException e) { errors.add(cls.getName() + " — missing public no-arg constructor"); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package dev.relism.flash.routing;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.PathParams;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.fpr.core.MatchResult;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
|
||||
public abstract class AbstractWsRouter {
|
||||
|
||||
public final AbstractWsRouter register(HttpMethod method, String path, WebSocketHandler handler) {
|
||||
return addRoute(method, PathUtils.sanitize(path), handler);
|
||||
}
|
||||
|
||||
public abstract WebSocketHandler route(Request request);
|
||||
|
||||
protected abstract AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler);
|
||||
|
||||
protected static void setPathParams(Request request, MatchResult<WebSocketHandler> result,
|
||||
String[] allNames, int methodLen) {
|
||||
int count = result.paramCount();
|
||||
if (count == 0) return;
|
||||
String[] names = new String[count];
|
||||
int[] starts = new int[count];
|
||||
int[] lens = new int[count];
|
||||
for (int i = 0; i < count; i++) {
|
||||
names[i] = allNames[result.keyIdAt(i)];
|
||||
starts[i] = result.startAt(i) - methodLen;
|
||||
lens[i] = result.lenAt(i);
|
||||
}
|
||||
PathParams.inject(request,
|
||||
new PathParams(request.getRequestLine().getPath(), names, starts, lens));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package dev.relism.flash.routing;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Marks a {@link dev.relism.flash.models.RequestHandler} subclass as a
|
||||
* WebSocket endpoint. Resolved at boot time by {@link Routes#of} via the
|
||||
* existing meta-annotation mechanism — no additional reflection required.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Ws("/chat")
|
||||
* public class ChatHandler extends RequestHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Route(method = HttpMethod.GET, path = "")
|
||||
public @interface Ws {
|
||||
String value();
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package dev.relism.flash.routing.routers.fastpathrouter;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.fpr.core.FastPathRouter;
|
||||
import dev.relism.fpr.core.MatchResult;
|
||||
import dev.relism.fpr.core.RouterBuilder;
|
||||
import dev.relism.fpr.core.dsl.StringRouteParser;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.routing.AbstractWsRouter;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
|
||||
public final class FastPathWsRouterImpl extends AbstractWsRouter {
|
||||
|
||||
private final RouterBuilder<WebSocketHandler> builder = new RouterBuilder<>();
|
||||
private volatile FastPathRouter<ByteView, WebSocketHandler> router;
|
||||
private String[] cachedParamNames;
|
||||
|
||||
@Override
|
||||
protected AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler) {
|
||||
builder.add(StringRouteParser.parse(method.name() + path), handler);
|
||||
this.router = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebSocketHandler route(Request request) {
|
||||
ensureCompiled();
|
||||
|
||||
MatchResult<WebSocketHandler> result = Context.result();
|
||||
result.reset();
|
||||
|
||||
HttpMethod method = request.getRequestLine().getMethod();
|
||||
ByteView pathView = request.getRequestLine().getPath();
|
||||
FastPathViews.MethodPathByteView combined = Context.combined();
|
||||
combined.reset(method.getBytes(), pathView);
|
||||
|
||||
int labelId = router.match(combined, result);
|
||||
if (labelId == FastPathRouter.NO_MATCH) return null;
|
||||
|
||||
if (result.paramCount() > 0) {
|
||||
setPathParams(request, result, cachedParamNames, method.getBytes().length);
|
||||
}
|
||||
|
||||
return result.handler();
|
||||
}
|
||||
|
||||
private void ensureCompiled() {
|
||||
if (router == null) {
|
||||
synchronized (this) {
|
||||
if (router == null) {
|
||||
cachedParamNames = builder.paramNames();
|
||||
router = builder.compile();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Context {
|
||||
private static final ThreadLocal<MatchResult<WebSocketHandler>> RESULT =
|
||||
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));
|
||||
private static final ThreadLocal<FastPathViews.MethodPathByteView> COMBINED =
|
||||
ThreadLocal.withInitial(FastPathViews.MethodPathByteView::new);
|
||||
|
||||
static MatchResult<WebSocketHandler> result() { return RESULT.get(); }
|
||||
static FastPathViews.MethodPathByteView combined() { return COMBINED.get(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Base class for class-based WebSocket endpoints registered via {@code @Ws} or
|
||||
* {@code app.ws(path, endpoint)}.
|
||||
*
|
||||
* Lifecycle: bind() once at boot → onOpen / onMessage* / onClose | onError per connection.
|
||||
* No relation to {@link dev.relism.flash.models.RequestHandler} — HTTP and WS lifecycles
|
||||
* are fully separate.
|
||||
*/
|
||||
public abstract class WebSocketEndpoint implements WebSocketHandler {
|
||||
|
||||
private FlashContext ctx;
|
||||
|
||||
/** Called once by framework at boot. Do not call from user code. */
|
||||
public final void bind(FlashContext ctx) {
|
||||
this.ctx = ctx;
|
||||
onInit();
|
||||
}
|
||||
|
||||
/** Override to resolve and cache services before first connection. */
|
||||
protected void onInit() {}
|
||||
|
||||
protected <T> T require(Class<T> type) {
|
||||
checkBound();
|
||||
return ctx.require(type);
|
||||
}
|
||||
|
||||
protected <T> Optional<T> find(Class<T> type) {
|
||||
checkBound();
|
||||
return ctx.find(type);
|
||||
}
|
||||
|
||||
private void checkBound() {
|
||||
if (ctx == null) throw new IllegalStateException(
|
||||
getClass().getSimpleName() + " not bound — register via app.scan() or app.ws()");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
/**
|
||||
* Zero-copy view over {@link WebSocketSession}'s read buffer.
|
||||
* One instance allocated per connection, reset per frame — zero steady-state allocation.
|
||||
*
|
||||
* <p><b>Lifetime contract:</b> valid ONLY within the synchronous scope of
|
||||
* {@link WebSocketHandler#onMessage(WebSocketSession, WebSocketFrame)}.
|
||||
* The underlying buffer is owned by {@link WebSocketSession} and will be
|
||||
* overwritten on the next frame read. Never retain a reference beyond the
|
||||
* callback; use {@link #copyPayload()} when data must outlive the call.
|
||||
*
|
||||
* <p><b>Debug safety:</b> {@link #buffer()} is package-private to prevent
|
||||
* accidental cross-boundary access. External consumers must go through
|
||||
* {@link #copyPayload()} or process in-place within the callback.
|
||||
*/
|
||||
public final class WebSocketFrame {
|
||||
|
||||
public static final byte OP_CONTINUATION = 0x0;
|
||||
public static final byte OP_TEXT = 0x1;
|
||||
public static final byte OP_BINARY = 0x2;
|
||||
public static final byte OP_CLOSE = 0x8;
|
||||
public static final byte OP_PING = 0x9;
|
||||
public static final byte OP_PONG = 0xA;
|
||||
|
||||
private byte[] buf;
|
||||
private int payloadOff;
|
||||
private int payloadLen;
|
||||
private byte opcode;
|
||||
private boolean fin;
|
||||
|
||||
// Monotonically incremented on every reset. Allows callers that hold a
|
||||
// reference beyond onMessage() to detect stale access in assertions/tests.
|
||||
private int generation;
|
||||
|
||||
/** Called by {@link WebSocketSession} only. */
|
||||
void reset(byte[] buf, int off, int len, byte opcode, boolean fin) {
|
||||
this.buf = buf;
|
||||
this.payloadOff = off;
|
||||
this.payloadLen = len;
|
||||
this.opcode = opcode;
|
||||
this.fin = fin;
|
||||
this.generation++;
|
||||
}
|
||||
|
||||
public byte opcode() { return opcode; }
|
||||
public boolean isFin() { return fin; }
|
||||
public int payloadOffset() { return payloadOff; }
|
||||
public int payloadLength() { return payloadLen; }
|
||||
|
||||
/**
|
||||
* Returns the generation counter at the moment of this call.
|
||||
* Store it at callback entry and compare later to detect stale retention:
|
||||
* <pre>{@code
|
||||
* int gen = frame.generation();
|
||||
* executor.submit(() -> {
|
||||
* assert frame.generation() == gen : "frame buffer was recycled!";
|
||||
* });
|
||||
* }</pre>
|
||||
*/
|
||||
public int generation() { return generation; }
|
||||
|
||||
/**
|
||||
* Direct reference to the session read buffer — package-private to prevent
|
||||
* accidental retention outside the websocket package.
|
||||
* External callers: use {@link #copyPayload()}.
|
||||
*/
|
||||
byte[] buffer() { return buf; }
|
||||
|
||||
/**
|
||||
* Copies the payload into a fresh array. Allocates — O(n) in payload size.
|
||||
* Use only when data must outlive the {@code onMessage} callback or be
|
||||
* handed off to another thread. For payloads > a few KB consider wrapping
|
||||
* the result in a pooled {@link java.nio.ByteBuffer} to avoid GC pressure.
|
||||
*/
|
||||
public byte[] copyPayload() {
|
||||
byte[] copy = new byte[payloadLen];
|
||||
System.arraycopy(buf, payloadOff, copy, 0, payloadLen);
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
/**
|
||||
* Handler for a WebSocket endpoint. Lifecycle:
|
||||
* onOpen → onMessage* → onClose | onError.
|
||||
*
|
||||
* Lifetime contract for {@link WebSocketFrame}: valid ONLY within
|
||||
* the synchronous scope of {@link #onMessage}. Do not retain references
|
||||
* to the frame or its buffer across calls — copy with {@link WebSocketFrame#copyPayload()}
|
||||
* if data must outlive the callback.
|
||||
*
|
||||
* OP_CONTINUATION frames are delivered as-is; fragment reassembly is an
|
||||
* application concern.
|
||||
*/
|
||||
public interface WebSocketHandler {
|
||||
void onOpen(WebSocketSession session);
|
||||
void onMessage(WebSocketSession session, WebSocketFrame frame);
|
||||
default void onClose(WebSocketSession session, int code) {}
|
||||
default void onError(WebSocketSession session, Throwable t) {}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Per-connection WebSocket I/O state. One instance per virtual thread.
|
||||
*
|
||||
* <ul>
|
||||
* <li>
|
||||
* <p>With {@code TCP_NODELAY} enabled on the socket (set in {@code HttpServer}),
|
||||
* Nagle's algorithm is disabled: the kernel sends data as soon as it lands in
|
||||
* the send buffer, without waiting. {@link java.io.BufferedOutputStream} will
|
||||
* still batch multiple small writes into one syscall when they happen in the
|
||||
* same virtual-thread scheduling quantum — giving us coalescing where it's
|
||||
* free, and immediate delivery where it matters.
|
||||
*
|
||||
* <p>The only place an explicit flush is still needed is after the WS
|
||||
* handshake (one-time, not on the hot path) and after the CLOSE frame
|
||||
* (end of session). Both are handled in {@link #close} and in
|
||||
* {@code HttpServer#performHandshake}.</li>
|
||||
*
|
||||
* <li><b>Flush on CLOSE frame</b>: {@link #close} still flushes explicitly
|
||||
* because the CLOSE frame is the last thing written before the stream is
|
||||
* abandoned — without a flush, the 4 bytes could sit in the buffer forever.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>Thread safety</h3>
|
||||
* {@link #sendText}, {@link #send}, and {@link #close} are synchronized on
|
||||
* {@code out} and safe to call from threads other than the session loop.
|
||||
* {@link #close} uses a CAS on {@code open} to guarantee exactly-once CLOSE
|
||||
* frame emission under concurrent calls.
|
||||
*/
|
||||
public final class WebSocketSession {
|
||||
|
||||
private final InputStream in;
|
||||
private final OutputStream out;
|
||||
private final byte[] readBuf;
|
||||
|
||||
private final AtomicBoolean open = new AtomicBoolean(true);
|
||||
private int closeCode = 1000;
|
||||
|
||||
private final byte[] hdrScratch = new byte[10];
|
||||
|
||||
public WebSocketSession(InputStream in, OutputStream out, int bufferSize) {
|
||||
this.in = in;
|
||||
this.out = out;
|
||||
this.readBuf = new byte[bufferSize];
|
||||
}
|
||||
|
||||
public boolean isOpen() { return open.get(); }
|
||||
public int closeCode() { return closeCode; }
|
||||
|
||||
// ── Public send API ────────────────────────────────────────────────────
|
||||
|
||||
public void sendText(byte[] utf8, int off, int len) throws IOException {
|
||||
writeFrame(WebSocketFrame.OP_TEXT, utf8, off, len);
|
||||
}
|
||||
|
||||
public void send(byte[] payload, int off, int len) throws IOException {
|
||||
writeFrame(WebSocketFrame.OP_BINARY, payload, off, len);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a CLOSE frame exactly once.
|
||||
* No flush needed — {@code out} is the raw socket OutputStream (not buffered);
|
||||
* each write goes directly to the kernel send buffer, and TCP_NODELAY ensures
|
||||
* it's transmitted immediately.
|
||||
*/
|
||||
public void close(int code) throws IOException {
|
||||
if (!open.compareAndSet(true, false)) return;
|
||||
synchronized (out) {
|
||||
out.write(0x88);
|
||||
out.write(0x02);
|
||||
out.write((code >> 8) & 0xFF);
|
||||
out.write(code & 0xFF);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session loop internals ─────────────────────────────────────────────
|
||||
|
||||
public boolean readFrame(WebSocketFrame frame) throws IOException {
|
||||
int b0 = in.read();
|
||||
if (b0 < 0) return false;
|
||||
int b1 = in.read();
|
||||
if (b1 < 0) return false;
|
||||
|
||||
boolean fin = (b0 & 0x80) != 0;
|
||||
byte opcode = (byte) (b0 & 0x0F);
|
||||
boolean masked = (b1 & 0x80) != 0;
|
||||
long payLen = (b1 & 0x7F);
|
||||
|
||||
if (payLen == 126) {
|
||||
payLen = ((in.read() & 0xFF) << 8) | (in.read() & 0xFF);
|
||||
} else if (payLen == 127) {
|
||||
payLen = 0;
|
||||
for (int i = 0; i < 8; i++) payLen = (payLen << 8) | (in.read() & 0xFF);
|
||||
}
|
||||
|
||||
if (payLen > readBuf.length) throw new IOException(
|
||||
"WS frame payload " + payLen + " bytes exceeds buffer " + readBuf.length);
|
||||
|
||||
byte m0 = 0, m1 = 0, m2 = 0, m3 = 0;
|
||||
if (masked) {
|
||||
m0 = (byte) in.read(); m1 = (byte) in.read();
|
||||
m2 = (byte) in.read(); m3 = (byte) in.read();
|
||||
}
|
||||
|
||||
int len = (int) payLen;
|
||||
readFully(readBuf, 0, len);
|
||||
|
||||
if (masked) unmaskInPlace(readBuf, 0, len, m0, m1, m2, m3);
|
||||
|
||||
frame.reset(readBuf, 0, len, opcode, fin);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void sendPong(WebSocketFrame ping) throws IOException {
|
||||
writeFrame(WebSocketFrame.OP_PONG, ping.buffer(), ping.payloadOffset(), ping.payloadLength());
|
||||
}
|
||||
|
||||
public void echo(WebSocketFrame frame) throws IOException {
|
||||
if (frame.opcode() == WebSocketFrame.OP_TEXT)
|
||||
sendText(frame.buffer(), frame.payloadOffset(), frame.payloadLength());
|
||||
else
|
||||
send(frame.buffer(), frame.payloadOffset(), frame.payloadLength());
|
||||
}
|
||||
|
||||
public void closeFromPeer(WebSocketFrame frame) {
|
||||
if (frame.payloadLength() >= 2) {
|
||||
byte[] b = frame.copyPayload();
|
||||
int o = frame.payloadOffset();
|
||||
closeCode = ((b[o] & 0xFF) << 8) | (b[o + 1] & 0xFF);
|
||||
}
|
||||
open.set(false);
|
||||
}
|
||||
|
||||
public void forceClose() {
|
||||
open.set(false);
|
||||
try { in.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encodes the WS frame header into {@link #hdrScratch} (at most 10 bytes),
|
||||
* then writes header + payload in two bulk calls to the raw socket stream.
|
||||
*
|
||||
* <p>No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream}
|
||||
* (see {@code HttpServer#process}). Each {@code write()} lands directly in the
|
||||
* kernel send buffer. With {@code TCP_NODELAY} set on the socket, the kernel
|
||||
* transmits the segment immediately without Nagle coalescing. The two writes
|
||||
* (header then payload) will be merged into a single TCP segment by the kernel
|
||||
* because they arrive faster than the ACK from the peer — exactly the coalescing
|
||||
* we want, at zero cost.
|
||||
*/
|
||||
private void writeFrame(byte opcode, byte[] payload, int off, int len) throws IOException {
|
||||
synchronized (out) {
|
||||
int hlen = 0;
|
||||
hdrScratch[hlen++] = (byte) (0x80 | opcode);
|
||||
if (len <= 125) {
|
||||
hdrScratch[hlen++] = (byte) len;
|
||||
} else if (len <= 0xFFFF) {
|
||||
hdrScratch[hlen++] = 126;
|
||||
hdrScratch[hlen++] = (byte) ((len >> 8) & 0xFF);
|
||||
hdrScratch[hlen++] = (byte) (len & 0xFF);
|
||||
} else {
|
||||
hdrScratch[hlen++] = 127;
|
||||
hdrScratch[hlen++] = 0; hdrScratch[hlen++] = 0;
|
||||
hdrScratch[hlen++] = 0; hdrScratch[hlen++] = 0;
|
||||
hdrScratch[hlen++] = (byte) ((len >> 24) & 0xFF);
|
||||
hdrScratch[hlen++] = (byte) ((len >> 16) & 0xFF);
|
||||
hdrScratch[hlen++] = (byte) ((len >> 8) & 0xFF);
|
||||
hdrScratch[hlen++] = (byte) (len & 0xFF);
|
||||
}
|
||||
out.write(hdrScratch, 0, hlen);
|
||||
out.write(payload, off, len);
|
||||
// No flush — TCP_NODELAY handles delivery. See Javadoc above.
|
||||
}
|
||||
}
|
||||
|
||||
private void readFully(byte[] buf, int off, int len) throws IOException {
|
||||
int remaining = len;
|
||||
while (remaining > 0) {
|
||||
int n = in.read(buf, off + (len - remaining), remaining);
|
||||
if (n < 0) throw new EOFException("WebSocket stream closed mid-frame");
|
||||
remaining -= n;
|
||||
}
|
||||
}
|
||||
|
||||
private static void unmaskInPlace(byte[] buf, int off, int len,
|
||||
byte m0, byte m1, byte m2, byte m3) {
|
||||
int i = off;
|
||||
int end = off + len;
|
||||
int end4 = off + (len & ~3);
|
||||
while (i < end4) {
|
||||
buf[i] ^= m0;
|
||||
buf[i+1] ^= m1;
|
||||
buf[i+2] ^= m2;
|
||||
buf[i+3] ^= m3;
|
||||
i += 4;
|
||||
}
|
||||
if (i < end) { buf[i++] ^= m0; }
|
||||
if (i < end) { buf[i++] ^= m1; }
|
||||
if (i < end) { buf[i] ^= m2; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user