optimized dynamic body size impl, decluttering javadocs/comments

This commit is contained in:
Relism
2026-03-15 17:14:17 +01:00
parent b0d606cb5f
commit 94d029a631
17 changed files with 196 additions and 409 deletions
+24 -89
View File
@@ -13,32 +13,17 @@ import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
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.TimeUnit;
/**
* Entry point for the Flash HTTP server.
*
* <p>
* Internally, the server owns a {@link GlobalRouter} with two tiers of routing:
* <ol>
* <li><b>Mounted sub-routers</b> — registered via {@link #mount}. Each owns a
* namespace
* prefix (e.g. {@code /api}) and handles all paths under it. Matched by longest
* prefix first.</li>
* <li><b>Internal router</b> — the fallback used when no sub-router claims the
* path.
* Routes registered directly on the server ({@link #get}, {@link #post}, etc.)
* go here.</li>
* </ol>
*
* <p>
* Error handlers ({@link #onNotFound}, {@link #onException}) registered on the
* server
* apply only to the internal router. Each mounted sub-router has its own
* independent handlers.
* Flash HTTP server. Owns a {@link GlobalRouter} with two routing tiers:
* mounted sub-routers (matched by longest namespace prefix) and an internal
* router as fallback. Error handlers are scoped to their respective router.
*/
@Slf4j
public class HttpServer {
@@ -46,6 +31,7 @@ public class HttpServer {
private final ServerSocket serverSocket;
private final GlobalRouter globalRouter = new GlobalRouter();
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
private volatile boolean stopped = false;
private final CompletableFuture<Void> readyFuture = new CompletableFuture<>();
@@ -69,11 +55,7 @@ public class HttpServer {
this.serverSocket = new ServerSocket(configuration.getPort());
}
/**
* Starts the server on a new non-daemon virtual thread and returns a future
* that completes
* when the accept loop is running and the server is ready to serve requests.
*/
/** Returns a future that completes once the accept loop is running and the server is ready. */
public CompletableFuture<Void> start() {
Thread.ofPlatform().name("flash-accept-loop").daemon(false).start(this::run);
return readyFuture;
@@ -94,10 +76,7 @@ public class HttpServer {
}
}
/**
* Stops the server and waits for in-flight requests to complete.
* The returned future is already completed when this method returns.
*/
/** Closes all active connections and shuts down the executor. Returns when complete. */
public CompletableFuture<Void> stop() {
stopped = true;
try {
@@ -105,6 +84,7 @@ public class HttpServer {
} catch (IOException e) {
log.error("Error closing server socket", e);
}
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} });
executorService.shutdown();
try {
if (!executorService.awaitTermination(30, TimeUnit.SECONDS))
@@ -116,104 +96,66 @@ public class HttpServer {
return CompletableFuture.completedFuture(null);
}
// --- Routing ---
/**
* Mounts a sub-router under the given namespace prefix.
* Mounts a sub-router under {@code namespace}. Requests whose path starts with the
* namespace are dispatched to {@code router}; longest prefix wins.
*
* <p>
* Any request whose path starts with {@code namespace} will be dispatched to
* {@code router}
* instead of the internal router. When multiple namespaces match, the longest
* prefix wins.
* The router's own {@code onNotFound} and {@code onException} handlers are used
* for its paths —
* the server-level handlers do not apply.
*
* @throws dev.relism.exceptions.DuplicateNamespaceException if
* {@code namespace}
* is already mounted
* @throws dev.relism.exceptions.DuplicateNamespaceException if {@code namespace} is already mounted
*/
public HttpServer mount(String namespace, AbstractRouter router) {
globalRouter.mount(namespace, router);
return this;
}
// --- Global error handlers ---
/**
* Sets the 404 handler for routes registered directly on this server.
* Does not affect mounted sub-routers, which each carry their own not-found
* handler.
*/
public HttpServer onNotFound(SimpleHandler.FunctionalHandler handler) {
globalRouter.onNotFound(handler);
return this;
}
/**
* Sets the exception handler for routes registered directly on this server.
* Does not affect mounted sub-routers, which each carry their own exception
* handler.
*/
public HttpServer onException(AbstractRouter.ExceptionHandler handler) {
globalRouter.onException(handler);
return this;
}
// --- DSL ---
/**
* Registers an annotation-based handler on the internal router.
* The handler's class must carry a {@link dev.relism.routing.Route @Route}
* annotation
* declaring the HTTP method and path. To register on a specific sub-router,
* call
* {@link AbstractRouter#register} on that router directly.
*/
public HttpServer register(RequestHandler handler) {
globalRouter.register(handler);
return this;
}
/**
* Registers a route on the internal router (not on any mounted sub-router).
* The handler's return value is used as the response body; returning a
* {@link dev.relism.models.Response}
* instance replaces the entire response object.
* Registers a route on the internal router. The handler return value drives the response:
* return a {@link dev.relism.models.Response} to replace it entirely, any other non-null
* value to set it as the body, or {@code null} to leave the response object unchanged.
*/
public HttpServer get(String path, SimpleHandler.FunctionalHandler h) {
globalRouter.get(path, h);
return this;
}
/** @see #get(String, SimpleHandler.FunctionalHandler) */
public HttpServer post(String path, SimpleHandler.FunctionalHandler h) {
globalRouter.post(path, h);
return this;
}
/** @see #get(String, SimpleHandler.FunctionalHandler) */
public HttpServer put(String path, SimpleHandler.FunctionalHandler h) {
globalRouter.put(path, h);
return this;
}
/** @see #get(String, SimpleHandler.FunctionalHandler) */
public HttpServer delete(String path, SimpleHandler.FunctionalHandler h) {
globalRouter.delete(path, h);
return this;
}
// --- Request processing ---
private void process(Socket socket) {
activeSockets.add(socket);
executorService.submit(() -> {
try (socket;
InputStream in = socket.getInputStream();
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
RequestParser parser = new RequestParser(configuration.getMaxHeaderBufferSize());
while (!stopped) {
Request request = RequestParser.parse(in);
Request request = parser.parse(in);
if (request == null)
break;
@@ -236,7 +178,6 @@ public class HttpServer {
response.setBody(result);
}
// Drain any unread body bytes so the stream is positioned at the next request.
request.getBody();
out.write(HTTP_1_1);
@@ -260,24 +201,18 @@ public class HttpServer {
} catch (IOException e) {
if (!stopped)
log.error("I/O error handling request", e);
} finally {
activeSockets.remove(socket);
}
});
}
/**
* Returns true if the connection should be kept alive after this request.
* HTTP/1.1 default: keep-alive. HTTP/1.0 default: close.
* Explicit {@code Connection} header overrides the default.
*/
private static boolean isKeepAlive(Request request) {
String connection = request.getHeader("Connection");
if (connection != null) {
return !"close".equalsIgnoreCase(connection);
}
// Detect HTTP version from last byte of protocol field: "HTTP/1.1" → '1',
// "HTTP/1.0" → '0'
if (request.headerEquals("Connection", "close")) return false;
// Detect HTTP version from last byte of protocol field: "HTTP/1.1" → '1', "HTTP/1.0" → '0'
ByteView protocol = request.getRequestLine().getProtocol();
return protocol.length() == 8 && protocol.byteAt(7) == '1';
return protocol.length() == 8 && protocol.byteAt(7) == '1'
|| request.headerEquals("Connection", "keep-alive");
}
private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException {
@@ -8,4 +8,6 @@ import lombok.Data;
public class HttpServerConfiguration {
private int port;
private String host;
@Builder.Default
private int maxHeaderBufferSize = 64 * 1024;
}
@@ -10,25 +10,35 @@ import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
/**
* Extreme Zero-Allocation Request Parser.
* Scans raw bytes to identify paths, protocols, and headers without intermediate String objects.
* Uses buffered reading and direct byte comparisons for maximum performance.
* 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.
*/
@Slf4j
public class RequestParser {
private static final int MAX_HEADER_SIZE = 8192;
private static final int INITIAL_BUFFER_SIZE = 8192;
public static Request parse(InputStream in) throws IOException {
byte[] buffer = new byte[MAX_HEADER_SIZE];
private final int maxHeaderBufferSize;
private byte[] buffer;
// 1. Robust read loop — accumulate until \r\n\r\n found or buffer full.
// prevTotal tracked per iteration so findEndOfHeader starts from max(0, prevTotal-3),
// skipping already-scanned bytes. The -3 overlap catches \r\n\r\n split across two reads.
public RequestParser() { this(64 * 1024); }
public RequestParser(int maxHeaderBufferSize) {
this.maxHeaderBufferSize = maxHeaderBufferSize;
this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)];
}
public Request parse(InputStream in) throws IOException {
int totalRead = 0;
int headerEndIdx = -1;
while (totalRead < buffer.length) {
while (true) {
if (totalRead == buffer.length) {
if (buffer.length >= maxHeaderBufferSize)
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
buffer = Arrays.copyOf(buffer, Math.min(buffer.length * 2, maxHeaderBufferSize));
}
int n = in.read(buffer, totalRead, buffer.length - totalRead);
if (n <= 0) break;
int prevTotal = totalRead;
@@ -38,10 +48,9 @@ public class RequestParser {
}
if (totalRead <= 0) return null;
if (headerEndIdx == -1) {
throw new IOException("Headers too large: buffer exhausted without finding \\r\\n\\r\\n");
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
}
// 2. Scan Request Line: METHOD PATH PROTOCOL
int methodEnd = find(buffer, 0, headerEndIdx, (byte) ' ');
if (methodEnd == -1) throw new IOException("Invalid request line (method)");
@@ -52,7 +61,6 @@ public class RequestParser {
int pathEnd = find(buffer, pathStart, headerEndIdx, (byte) ' ');
if (pathEnd == -1) throw new IOException("Invalid request line (path)");
// Split path from query string at '?' — FPR only sees the clean path
int queryMark = find(buffer, pathStart, pathEnd, (byte) '?');
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart,
queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart);
@@ -66,7 +74,6 @@ public class RequestParser {
FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
// 3. Scan Headers — store raw offsets into buffer, zero objects allocated per header
HeaderMap headerMap = new HeaderMap(buffer);
int current = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
int contentLength = 0;
@@ -82,16 +89,13 @@ public class RequestParser {
headerMap.add(current, colon - current, valueStart, lineEnd - valueStart);
// Direct byte comparison to extract Content-Length without String allocation
if (equalsIgnoreCase(buffer, current, colon, "content-length")) {
contentLength = parseInt(buffer, valueStart, lineEnd);
}
}
current = lineEnd + 2; // skip \r\n
current = lineEnd + 2;
}
// Body is read lazily — only materialized if the handler calls req.getBody().
// Bytes already in the buffer past \r\n\r\n are handed off to LazyBody as read-ahead.
int bodyStart = headerEndIdx + 4;
int preBufLen = totalRead - bodyStart;
return Request.forParsed(
@@ -8,8 +8,7 @@ import java.util.List;
/**
* Zero-allocation header storage backed by raw byte offsets into the shared request buffer.
* At parse time, only int offsets are recorded — zero objects allocated per header.
* String conversion is lazy: happens only when getFirst() or getAll() are called.
* Offsets are recorded at parse time; Strings are allocated only on {@link #getFirst} / {@link #getAll}.
*/
public class HeaderMap {
private static final int MAX_HEADERS = 32;
@@ -23,7 +22,6 @@ public class HeaderMap {
this.buffer = buffer;
}
/** Called at parse time. Stores raw offsets — zero allocations. */
public void add(int keyStart, int keyLen, int valStart, int valLen) {
if (count >= MAX_HEADERS) throw new IllegalStateException("Too many headers: limit is " + MAX_HEADERS);
keys[count * 2] = keyStart;
@@ -33,14 +31,12 @@ public class HeaderMap {
count++;
}
/** Lazy: decodes and returns the first matching value as a String. */
public String getFirst(String name) {
int idx = indexOf(name);
if (idx < 0) return null;
return new String(buffer, values[idx * 2], values[idx * 2 + 1], StandardCharsets.UTF_8);
}
/** Lazy: decodes and returns all matching values as a List<String>. */
public List<String> getAll(String name) {
List<String> result = null;
for (int i = 0; i < count; i++) {
@@ -52,7 +48,6 @@ public class HeaderMap {
return result != null ? result : List.of();
}
/** Lazy: decodes and returns all values as a List<String>. */
public List<String> getAll() {
List<String> result = new ArrayList<>();
for (int i = 0; i < count; i++) {
@@ -61,7 +56,21 @@ public class HeaderMap {
return result;
}
/** Zero-copy: returns a ByteView over the raw value bytes without allocating a String. */
public boolean headerValueEqualsIgnoreCase(String name, String value) {
int idx = indexOf(name);
if (idx < 0) return false;
int vs = values[idx * 2], vl = values[idx * 2 + 1];
if (vl != value.length()) return false;
for (int i = 0; i < vl; i++) {
byte b = buffer[vs + i];
if (b >= 'A' && b <= 'Z') b += 32;
char c = value.charAt(i);
if (c >= 'A' && c <= 'Z') c += 32;
if (b != (byte) c) return false;
}
return true;
}
public ByteView getView(String name) {
int idx = indexOf(name);
if (idx < 0) return null;
@@ -80,7 +89,6 @@ public class HeaderMap {
return -1;
}
/** Case-insensitive comparison between a buffer slice and a String. Zero allocations. */
private boolean keyMatches(int i, String name) {
int ks = keys[i * 2], kl = keys[i * 2 + 1];
if (kl != name.length()) return false;
@@ -5,14 +5,8 @@ import java.io.InputStream;
import java.io.UncheckedIOException;
/**
* Deferred request body reader.
*
* <p>On the first call to {@link #get()}, the body is materialized: any bytes already
* buffered from the 8 KB header read-ahead are copied first, then the remainder is pulled
* from the socket stream. The result is cached so subsequent calls are free.
*
* <p>When {@code contentLength} is zero the instance is pre-resolved to an empty array
* and no stream access ever occurs.
* Deferred body reader. On the first {@link #get()} call, pre-buffered bytes from the header
* read-ahead are used first, then the remainder is pulled from the socket stream. Result is cached.
*/
final class LazyBody {
private static final byte[] EMPTY = new byte[0];
@@ -32,14 +26,12 @@ final class LazyBody {
this.preBufLen = preBufLen;
}
/** Returns a pre-resolved {@code LazyBody} backed by an already-materialized byte array. */
static LazyBody of(byte[] bytes) {
LazyBody lb = new LazyBody(null, bytes.length, null, 0, 0);
lb.resolved = bytes;
return lb;
}
/** Returns a pre-resolved empty {@code LazyBody}. */
static LazyBody empty() {
LazyBody lb = new LazyBody(null, 0, null, 0, 0);
lb.resolved = EMPTY;
@@ -5,11 +5,8 @@ import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
/**
* Path parameters captured during routing.
*
* <p>Values are stored as byte offsets into the original path view. String conversion
* is lazy and happens only on {@link #get}; {@link #view} is zero-copy.
* Lookup is a linear scan — param counts are always small (typically 13).
* Path parameters captured during routing, stored as byte offsets into the path view.
* {@link #get} allocates a String on call; {@link #view} is zero-copy.
*/
public class PathParams {
private final ByteView source;
@@ -24,7 +21,6 @@ public class PathParams {
this.lens = lens;
}
/** Lazy — allocates a {@code String} only on call. {@code null} if the param is absent. */
public String get(String name) {
int i = indexOf(name);
if (i < 0) return null;
@@ -33,7 +29,6 @@ public class PathParams {
return new String(bytes, StandardCharsets.UTF_8);
}
/** Zero-copy — returns a {@link ByteView} slice over the raw path bytes. */
ByteView view(String name) {
int i = indexOf(name);
if (i < 0) return null;
@@ -7,14 +7,10 @@ import java.util.ArrayList;
import java.util.List;
/**
* Lazy access to URL query parameters ({@code ?key=value&...}).
* Backed by a zero-copy {@link ByteView} over the raw query string bytes from the request buffer.
* No parsing happens at construction — values are decoded on demand.
*
* <p>{@link #view} is zero-copy; {@link #get} and {@link #getAll} allocate only the result String(s).
* Lazy query parameter access ({@code ?key=value&...}). Backed by a zero-copy {@link ByteView}
* over the raw query string bytes — no parsing at construction, values decoded on demand.
*/
public class QueryParams {
/** Singleton returned when the request has no query string. All methods return empty/null. */
public static final QueryParams EMPTY = new QueryParams(null);
private final ByteView raw;
@@ -23,7 +19,6 @@ public class QueryParams {
this.raw = raw;
}
/** Lazy — decodes the first value for {@code name}, or {@code null} if absent. */
public String get(String name) {
long r = findFirst(name);
if (r < 0) return null;
@@ -33,7 +28,6 @@ public class QueryParams {
return new String(bytes, StandardCharsets.UTF_8);
}
/** Zero-copy — returns a {@link ByteView} slice over the raw value bytes. */
ByteView view(String name) {
long r = findFirst(name);
if (r < 0) return null;
@@ -44,7 +38,6 @@ public class QueryParams {
};
}
/** Lazy — returns all values for {@code name}, or an empty list if absent. */
public List<String> getAll(String name) {
if (raw == null) return List.of();
List<String> result = null;
@@ -70,10 +63,7 @@ public class QueryParams {
return result != null ? result : List.of();
}
/**
* Scans for the first occurrence of {@code name=value}.
* Returns {@code (valStart << 32) | valLen}, or {@code -1} if not found. Zero allocations.
*/
/** Returns (valStart << 32) | valLen, or -1 if not found. */
private long findFirst(String name) {
if (raw == null) return -1L;
int i = 0, len = raw.length();
@@ -31,17 +31,10 @@ public class Request {
this.queryParams = null;
}
/** Convenience constructor for test mocks and pre-materialized bodies. */
public Request(RequestLine requestLine, byte[] body) {
this(requestLine, LazyBody.of(body));
}
/**
* Factory used by {@link dev.relism.RequestParser}: creates a request whose body is read
* from {@code stream} on the first call to {@link #getBody()}.
*
* @param preBufLen bytes already buffered past the header end (from the 8 KB read-ahead)
*/
public static Request forParsed(RequestLine requestLine, InputStream stream,
int contentLength, byte[] headerBuf,
int bodyStart, int preBufLen) {
@@ -51,30 +44,21 @@ public class Request {
return new Request(requestLine, lazy);
}
/** Materializes and returns the request body, reading from the socket if not yet done. */
public byte[] getBody() { return lazyBody.get(); }
// --- Header access (lazy: String allocated only on call) ---
public String getHeader(String name) { return requestLine.getHeaders().getFirst(name); }
public List<String> getHeaders(String name) { return requestLine.getHeaders().getAll(name); }
public List<String> getHeaders() { return requestLine.getHeaders().getAll(); }
public boolean headerEquals(String name, String value) { return requestLine.getHeaders().headerValueEqualsIgnoreCase(name, value); }
// --- Path param access ---
/** Lazy: decodes the named path parameter to a {@code String}. */
public String getPathParam(String name) {
return pathParams != null ? pathParams.get(name) : null;
}
// --- Query param access ---
/** Lazy: decodes the first value of {@code name}, or {@code null} if absent. */
public String getQueryParam(String name) {
return resolveQueryParams().get(name);
}
/** Lazy: decodes all values of {@code name} (e.g. {@code ?tag=a&tag=b}). */
public List<String> getQueryParams(String name) {
return resolveQueryParams().getAll(name);
}
@@ -12,17 +12,9 @@ import lombok.Getter;
import java.nio.charset.StandardCharsets;
/**
* Base class for all routers, used both for the server's internal router and for
* mounted sub-routers.
*
* <p>Each router has a <b>namespace</b> (e.g. {@code /api}). Routes added via
* {@link #get}, {@link #post}, etc. are <em>relative</em> to the namespace; the
* implementation prepends it when registering. The namespace is {@code "/"} by default
* and is set automatically by {@link GlobalRouter#mount}.
*
* <p>Error handlers ({@link #onNotFound}, {@link #onException}) are scoped to this router.
* When a sub-router is mounted on the server, its handlers take precedence over the
* server-level ones for all paths under its namespace.
* Base router. Each router has a namespace prefix (default {@code "/"}); routes added via
* {@link #get}, {@link #post}, etc. are relative to it. Error handlers are scoped to this router.
* The namespace is set automatically by {@link GlobalRouter#mount}.
*/
public abstract class AbstractRouter {
@@ -32,8 +24,6 @@ public abstract class AbstractRouter {
@Getter(AccessLevel.PACKAGE)
protected byte[] namespaceBytes = new byte[]{ '/' };
// --- Default handlers ---
protected SimpleHandler notFoundHandler = new SimpleHandler((req, res) -> {
res.setStatusCode(404);
res.setContentType(ContentType.TEXT_HTML);
@@ -46,7 +36,6 @@ public abstract class AbstractRouter {
return ErrorPages.renderException(req, ex);
};
// Package-private — used by GlobalRouter only
SimpleHandler getNotFoundHandler() { return notFoundHandler; }
ExceptionHandler getExceptionHandler() { return exceptionHandler; }
void setNamespace(String namespace) {
@@ -54,54 +43,37 @@ public abstract class AbstractRouter {
this.namespaceBytes = namespace.getBytes(StandardCharsets.UTF_8);
}
// --- Public API ---
/** Overrides the default 404 response for unmatched paths under this router's namespace. */
public AbstractRouter onNotFound(SimpleHandler.FunctionalHandler handler) {
this.notFoundHandler = new SimpleHandler(handler);
return this;
}
/** Overrides the default 500 response for uncaught exceptions thrown by handlers under this router. */
public AbstractRouter onException(ExceptionHandler handler) {
this.exceptionHandler = handler;
return this;
}
/**
* Registers a route relative to this router's namespace.
*
* <p>The handler's return value drives the response:
* <ul>
* <li>Return a {@link Response} to replace the entire response object.</li>
* <li>Return any other non-null value to use it as the body (via {@code toString()} or raw bytes).</li>
* <li>Return {@code null} to leave the response unchanged from what was set on the {@code Response} parameter.</li>
* </ul>
* Registers a route relative to this router's namespace. Return a {@link Response} to replace
* it entirely, any other non-null value to set it as the body, or {@code null} to leave it unchanged.
*/
public AbstractRouter get(String path, SimpleHandler.FunctionalHandler handler) {
return addRoute(HttpMethod.GET, PathUtils.sanitize(path), new SimpleHandler(handler));
}
/** @see #get(String, SimpleHandler.FunctionalHandler) */
public AbstractRouter post(String path, SimpleHandler.FunctionalHandler handler) {
return addRoute(HttpMethod.POST, PathUtils.sanitize(path), new SimpleHandler(handler));
}
/** @see #get(String, SimpleHandler.FunctionalHandler) */
public AbstractRouter put(String path, SimpleHandler.FunctionalHandler handler) {
return addRoute(HttpMethod.PUT, PathUtils.sanitize(path), new SimpleHandler(handler));
}
/** @see #get(String, SimpleHandler.FunctionalHandler) */
public AbstractRouter delete(String path, SimpleHandler.FunctionalHandler handler) {
return addRoute(HttpMethod.DELETE, PathUtils.sanitize(path), new SimpleHandler(handler));
}
/**
* Registers a class-based handler. The handler's class must be annotated with
* {@link Route @Route} declaring the HTTP method and path (relative to this router's namespace).
* If the annotation is absent, the call is silently ignored.
*/
/** Registers a class-based handler; the class must be annotated with {@link Route @Route}. */
public AbstractRouter register(RequestHandler handler) {
Route annotation = handler.getClass().getAnnotation(Route.class);
if (annotation != null) {
@@ -110,18 +82,10 @@ public abstract class AbstractRouter {
return this;
}
// --- For implementors ---
public abstract RequestHandler route(Request request);
protected abstract AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler);
/**
* Sets the path parameters captured during routing.
* Implementations must call this whenever the matched route contains path parameters.
* Centralised here so all router implementations participate in the same contract
* and produce a consistent {@link PathParams} regardless of the matching strategy.
*/
protected static void setPathParams(Request request, String[] names, ByteView source, int[] starts, int[] lens) {
request.setPathParams(new PathParams(source, names, starts, lens));
}
@@ -14,16 +14,8 @@ import java.util.List;
import java.util.Map;
/**
* Top-level dispatcher owned by {@link dev.relism.HttpServer}.
*
* <p>Routing order on each request:
* <ol>
* <li>Iterates mounted sub-routers in descending namespace length order (longest prefix first)
* and delegates to the first one whose namespace is a prefix of the request path.</li>
* <li>If no sub-router matches, falls through to the internal {@link FastPathRouterImpl}.</li>
* </ol>
*
* <p>Not intended to be instantiated or subclassed directly — use {@link dev.relism.HttpServer}.
* Top-level dispatcher. Routes to the longest-matching mounted sub-router first,
* falling back to the internal {@link FastPathRouterImpl}.
*/
public class GlobalRouter extends AbstractRouter {
private final Map<String, AbstractRouter> subRoutersMap = new HashMap<>();
@@ -53,7 +45,6 @@ public class GlobalRouter extends AbstractRouter {
return h != null ? h : notFoundHandler;
}
/** Resolves the scoped exception handler for the router that owns the request path. */
public ExceptionHandler resolveExceptionHandler(Request request) {
ByteView path = request.getRequestLine().getPath();
for (AbstractRouter sub : sortedSubRouters) {
@@ -12,30 +12,16 @@ import dev.relism.routing.AbstractRouter;
import dev.relism.routing.PathUtils;
/**
* Router implementation backed by the {@code fpr-core} byte-level state machine.
*
* <p>Routes are compiled lazily: the internal {@code FastPathRouter} is built on the first
* incoming request after one or more routes have been added. Adding a route after the server
* has started is safe — the router is marked dirty and recompiled on the next request.
*
* <p>Matching is done on a virtual {@code METHOD + path} byte sequence to avoid a two-step
* lookup. Captured path parameters are exposed via {@link dev.relism.models.PathParams} on
* the request object, accessible through {@link dev.relism.models.Request#getPathParams()}.
*
* <p>Thread safety: {@code FastPathRouter.match()} is safe for concurrent calls as of
* {@code fpr-core} 1.1.0 — traversal state ({@code RouteSearch}, {@code SegmentCursor}) is
* held in method-local variables, making the compiled router instance fully immutable.
* Router backed by the {@code fpr-core} byte-level state machine. Routes are compiled lazily
* on the first request and recompiled when routes are added after startup. Matching runs on a
* virtual {@code METHOD + path} byte sequence in a single pass; {@link MatchResult} and
* {@link FastPathViews.MethodPathByteView} are reused per-thread to avoid hot-path allocations.
*/
public class FastPathRouterImpl extends AbstractRouter {
private final RouterBuilder<RequestHandler> builder = new RouterBuilder<>();
private volatile FastPathRouter<ByteView, RequestHandler> router;
private String[] cachedParamNames;
/**
* Thread-local holders for per-request reusable objects.
* Both {@link MatchResult} and {@link FastPathViews.MethodPathByteView} are reset before use,
* so no allocation occurs on the routing hot path.
*/
private static final class FastPathRouterContext {
private static final ThreadLocal<MatchResult<RequestHandler>> RESULT_HOLDER =
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));
@@ -55,7 +41,7 @@ public class FastPathRouterImpl extends AbstractRouter {
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
String fullPath = PathUtils.join(namespace, path);
builder.add(StringRouteParser.parse(method.name() + fullPath), handler);
this.router = null; // Mark dirty
this.router = null;
return this;
}
@@ -77,7 +63,6 @@ public class FastPathRouterImpl extends AbstractRouter {
return null;
}
// CAPTURE PARAMETERS
int count = result.paramCount();
if (count > 0) {
int methodLen = method.getBytes().length;
@@ -97,10 +82,6 @@ public class FastPathRouterImpl extends AbstractRouter {
return result.handler();
}
/**
* Ensures the FPR state machine is compiled. Uses double-checked locking on a {@code volatile}
* field so the fast path (already compiled) is a single null-check with no synchronization.
*/
private void ensureCompiled() {
if (router == null) {
synchronized (this) {
@@ -5,17 +5,10 @@ import lombok.NoArgsConstructor;
import java.nio.charset.StandardCharsets;
/**
* Container class for various {@link ByteView} implementations used by the FastPathRouter.
* Consolidating these views reduces package clutter while maintaining high-performance byte access.
*/
/** {@link dev.relism.fpr.core.ByteView} implementations used on the router and parser hot paths. */
@NoArgsConstructor
public final class FastPathViews {
/**
* High-performance, zero-copy ByteView that points to a shared request buffer.
* Used by the {@link dev.relism.RequestParser} to scan for paths without allocations.
*/
public static final class RequestByteView implements ByteView {
private final byte[] buffer;
private final int start;
@@ -41,24 +34,17 @@ public final class FastPathViews {
}
@Override
public String toString() { // CHANGED: decode slice directly; avoids full-buffer String copy
public String toString() {
return new String(buffer, start, length, StandardCharsets.UTF_8);
}
}
/**
* A composite ByteView that prefixes a method's bytes to a path view.
* Enables method+path routing in a single pass with no allocation.
*
* <p>Mutable by design — reused across requests via {@code ThreadLocal}.
* Call {@link #reset} before each use.
*/
/** Mutable composite view: method bytes + path. Reused via ThreadLocal — call reset() before use. */
public static final class MethodPathByteView implements ByteView {
private byte[] method;
private ByteView path;
private int totalLength;
/** Binds this view to a new method+path pair. Must be called before each use. */
public void reset(byte[] method, ByteView path) {
this.method = method;
this.path = path;
@@ -76,9 +62,6 @@ public final class FastPathViews {
}
}
/**
* ByteView implementation for raw byte arrays, typically from a socket.
*/
public static class SocketByteView implements ByteView {
private final byte[] data;
@@ -97,9 +80,6 @@ public final class FastPathViews {
}
}
/**
* ByteView implementation for Java Strings.
*/
public static class StringByteView implements ByteView {
private final byte[] bytes;
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
@@ -62,17 +63,32 @@ class HttpServerTest {
try (Socket socket = new Socket("127.0.0.1", port);
OutputStream out = socket.getOutputStream();
InputStream in = socket.getInputStream()) {
out.write(rawHttp.getBytes(StandardCharsets.UTF_8));
out.flush();
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
baos.write(buffer, 0, read);
// Read until \r\n\r\n to get the full header block
ByteArrayOutputStream headerBuf = new ByteArrayOutputStream();
int b, prev3 = -1, prev2 = -1, prev1 = -1;
while ((b = in.read()) != -1) {
headerBuf.write(b);
if (prev3 == '\r' && prev2 == '\n' && prev1 == '\r' && b == '\n') break;
prev3 = prev2; prev2 = prev1; prev1 = b;
}
return baos.toString(StandardCharsets.UTF_8);
String headers = headerBuf.toString(StandardCharsets.UTF_8);
// Parse Content-Length
int contentLength = 0;
for (String line : headers.split("\r\n")) {
if (line.toLowerCase().startsWith("content-length:")) {
contentLength = Integer.parseInt(line.substring(line.indexOf(':') + 1).trim());
break;
}
}
// Read exactly Content-Length bytes for the body
byte[] body = in.readNBytes(contentLength);
return headers + new String(body, StandardCharsets.UTF_8);
}
}
@@ -16,7 +16,7 @@ class RequestParserTest {
private static Request parse(String raw) throws IOException {
byte[] bytes = raw.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
return RequestParser.parse(new ByteArrayInputStream(bytes));
return new RequestParser().parse(new ByteArrayInputStream(bytes));
}
private static String req(String requestLine, String... headers) {
@@ -70,7 +70,7 @@ class RequestParserTest {
void body_parsed() throws IOException {
String body = "hello body";
String raw = "POST / HTTP/1.1\r\nContent-Length: " + body.length() + "\r\n\r\n" + body;
Request r = RequestParser.parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
assertNotNull(r);
assertEquals(body, new String(r.getBody(), StandardCharsets.UTF_8));
}
@@ -85,14 +85,14 @@ class RequestParserTest {
@Test
void emptyInputStream_returnsNull() throws IOException {
assertNull(RequestParser.parse(new ByteArrayInputStream(new byte[0])));
assertNull(new RequestParser().parse(new ByteArrayInputStream(new byte[0])));
}
@Test
void missingHeaderTerminator_throwsIOException() {
// Valid request line but stream ends before \r\n\r\n
byte[] raw = "GET / HTTP/1.1\r\nHost: localhost\r\n".getBytes(StandardCharsets.UTF_8);
assertThrows(IOException.class, () -> RequestParser.parse(new ByteArrayInputStream(raw)));
assertThrows(IOException.class, () -> new RequestParser().parse(new ByteArrayInputStream(raw)));
}
@Test
@@ -107,11 +107,12 @@ class RequestParserTest {
}
@Test
void headersOverBufferSize_throwsIOException() {
// 9 KB of data with no \r\n\r\n exhausts the 8 KB buffer
byte[] giant = new byte[9000];
void headers_exceedingMaxBufferSize_throwsIOException() {
// Feed more bytes than the configured cap with no \r\n\r\n — must throw
int cap = 16 * 1024;
byte[] giant = new byte[cap + 1];
Arrays.fill(giant, (byte) 'A');
assertThrows(IOException.class, () -> RequestParser.parse(new ByteArrayInputStream(giant)));
assertThrows(IOException.class, () -> new RequestParser(cap).parse(new ByteArrayInputStream(giant)));
}
@Test
@@ -119,7 +120,7 @@ class RequestParserTest {
// Content-Length claims 50 but stream ends after 5 bytes
String body = "hello";
String raw = "POST / HTTP/1.1\r\nContent-Length: 50\r\n\r\n" + body;
Request r = RequestParser.parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
assertNotNull(r);
assertEquals(50, r.getBody().length);
assertEquals(body, new String(r.getBody(), 0, body.length(), StandardCharsets.UTF_8));