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
@@ -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}&lt;{@link MessageDigest}&gt; — 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();
}
@@ -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; }
}
}