refactored, pre-buffer reuse

This commit is contained in:
Relism
2026-03-15 14:31:52 +01:00
parent f1beb160aa
commit b0d606cb5f
64 changed files with 888 additions and 400 deletions
@@ -0,0 +1,8 @@
package dev.relism;
import lombok.NoArgsConstructor;
@NoArgsConstructor
public final class Flash {
public static final String VERSION = "5.0.0-dev";
}
@@ -0,0 +1,310 @@
package dev.relism;
import dev.relism.fpr.core.ByteView;
import dev.relism.http.ContentType;
import dev.relism.http.HttpStatus;
import dev.relism.models.*;
import dev.relism.routing.AbstractRouter;
import dev.relism.routing.GlobalRouter;
import lombok.extern.slf4j.Slf4j;
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
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.
*/
@Slf4j
public class HttpServer {
private final HttpServerConfiguration configuration;
private final ServerSocket serverSocket;
private final GlobalRouter globalRouter = new GlobalRouter();
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
private volatile boolean stopped = false;
private final CompletableFuture<Void> readyFuture = new CompletableFuture<>();
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[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
private static final byte[][] DIGITS = new byte[10][1];
static {
for (int i = 0; i < 10; i++)
DIGITS[i] = String.valueOf(i).getBytes(StandardCharsets.UTF_8);
}
public HttpServer(HttpServerConfiguration configuration) throws IOException {
this.configuration = configuration;
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.
*/
public CompletableFuture<Void> start() {
Thread.ofPlatform().name("flash-accept-loop").daemon(false).start(this::run);
return readyFuture;
}
private void run() {
try (executorService) {
readyFuture.complete(null);
while (!stopped) {
Socket clientSocket = serverSocket.accept();
process(clientSocket);
}
} catch (IOException e) {
if (!stopped) {
readyFuture.completeExceptionally(e);
log.error("Accept loop error", e);
}
}
}
/**
* Stops the server and waits for in-flight requests to complete.
* The returned future is already completed when this method returns.
*/
public CompletableFuture<Void> stop() {
stopped = true;
try {
serverSocket.close();
} catch (IOException e) {
log.error("Error closing server socket", e);
}
executorService.shutdown();
try {
if (!executorService.awaitTermination(30, TimeUnit.SECONDS))
executorService.shutdownNow();
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
return CompletableFuture.completedFuture(null);
}
// --- Routing ---
/**
* Mounts a sub-router under the given namespace prefix.
*
* <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
*/
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.
*/
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) {
executorService.submit(() -> {
try (socket;
InputStream in = socket.getInputStream();
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
while (!stopped) {
Request request = RequestParser.parse(in);
if (request == null)
break;
boolean keepAlive = isKeepAlive(request);
Response response = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
RequestHandler handler = globalRouter.route(request);
try {
Object result = handler.handle(request, response);
if (result instanceof Response r)
response = r;
else if (result != null)
response.setBody(result);
} catch (Exception ex) {
Object result = globalRouter.resolveExceptionHandler(request).handle(ex, request, response);
if (result instanceof Response r)
response = r;
else if (result != null)
response.setBody(result);
}
// Drain any unread body bytes so the stream is positioned at the next request.
request.getBody();
out.write(HTTP_1_1);
writeStatusPhrase(out, response.getStatusCode());
out.write(CRLF);
out.write(CONTENT_TYPE);
out.write(response.getContentType());
out.write(CRLF);
out.write(CONTENT_LENGTH);
writeInt(out, response.getBody() != null ? response.getBody().length : 0);
out.write(CRLF);
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF);
if (response.getBody() != null)
out.write(response.getBody());
out.flush();
if (!keepAlive)
break;
}
} catch (IOException e) {
if (!stopped)
log.error("I/O error handling request", e);
}
});
}
/**
* 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'
ByteView protocol = request.getRequestLine().getProtocol();
return protocol.length() == 8 && protocol.byteAt(7) == '1';
}
private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException {
byte[] phrase = HttpStatus.bytesForCode(statusCode);
if (phrase != null) {
out.write(phrase);
} else {
writeInt(out, statusCode);
out.write(UNKNOWN_STATUS_SUFFIX);
}
}
private static void writeInt(OutputStream out, int value) throws IOException {
if (value == 0) {
out.write(DIGITS[0]);
return;
}
if (value < 0) {
out.write('-');
value = -value;
}
int divisor = 1;
while (value / divisor >= 10)
divisor *= 10;
while (divisor > 0) {
out.write(DIGITS[(value / divisor) % 10]);
divisor /= 10;
}
}
}
@@ -0,0 +1,11 @@
package dev.relism;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class HttpServerConfiguration {
private int port;
private String host;
}
+47
View File
@@ -0,0 +1,47 @@
package dev.relism;
import dev.relism.http.ContentType;
import dev.relism.models.*;
import dev.relism.routing.Route;
import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
@Slf4j
public class Main {
private static final int PORT = 8080;
/*
@Route(method = "GET", path = "/profile")
public static class ProfileHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return "Class based handler: User profile info under /api/profile";
}
}
*/
public static void main(String[] args) throws IOException {
HttpServerConfiguration config = HttpServerConfiguration.builder()
.port(PORT)
.host("localhost")
.build();
HttpServer server = new HttpServer(config);
FastPathRouterImpl apiRouter = new FastPathRouterImpl();
server.mount("/api", apiRouter);
// apiRouter.register(new ProfileHandler());
server.get("/headers", (req, res) -> {
throw new RuntimeException(req.getQueryParam("test"));
});
server.start().thenRun(() -> log.info("Server started on port: " + PORT));
}
}
@@ -0,0 +1,137 @@
package dev.relism;
import dev.relism.models.HeaderMap;
import dev.relism.http.HttpMethod;
import dev.relism.models.Request;
import dev.relism.models.RequestLine;
import dev.relism.routing.routers.fastpathrouter.FastPathViews;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.io.InputStream;
/**
* 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.
*/
@Slf4j
public class RequestParser {
private static final int MAX_HEADER_SIZE = 8192;
public static Request parse(InputStream in) throws IOException {
byte[] buffer = new byte[MAX_HEADER_SIZE];
// 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.
int totalRead = 0;
int headerEndIdx = -1;
while (totalRead < buffer.length) {
int n = in.read(buffer, totalRead, buffer.length - totalRead);
if (n <= 0) break;
int prevTotal = totalRead;
totalRead += n;
headerEndIdx = findEndOfHeader(buffer, Math.max(0, prevTotal - 3), totalRead);
if (headerEndIdx != -1) break;
}
if (totalRead <= 0) return null;
if (headerEndIdx == -1) {
throw new IOException("Headers too large: buffer exhausted without finding \\r\\n\\r\\n");
}
// 2. Scan Request Line: METHOD PATH PROTOCOL
int methodEnd = find(buffer, 0, headerEndIdx, (byte) ' ');
if (methodEnd == -1) throw new IOException("Invalid request line (method)");
HttpMethod method = HttpMethod.fromBytes(buffer, 0, methodEnd);
if (method == null) throw new IOException("Unsupported HTTP method");
int pathStart = methodEnd + 1;
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);
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');
if (protocolEnd == -1) throw new IOException("Invalid request line (protocol)");
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;
while (current < headerEndIdx) {
int lineEnd = find(buffer, current, headerEndIdx + 1, (byte) '\r');
if (lineEnd == -1 || lineEnd == current) break;
int colon = find(buffer, current, lineEnd, (byte) ':');
if (colon != -1) {
int valueStart = colon + 1;
while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++;
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
}
// 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(
new RequestLine(method, pathView, queryView, protocolView, headerMap),
in, contentLength, buffer, bodyStart, preBufLen);
}
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') {
return i;
}
}
return -1;
}
private static int find(byte[] buf, int start, int end, byte target) {
for (int i = start; i < end; i++) {
if (buf[i] == target) return i;
}
return -1;
}
private static boolean equalsIgnoreCase(byte[] buf, int start, int end, String target) {
int len = end - start;
if (len != target.length()) return false;
for (int i = 0; i < len; i++) {
byte b = buf[start + i];
if (b >= 'A' && b <= 'Z') b += 32;
if (b != (byte) target.charAt(i)) return false;
}
return true;
}
private static int parseInt(byte[] buf, int start, int end) {
int value = 0;
for (int i = start; i < end; i++) {
byte c = buf[i];
if (c >= '0' && c <= '9') value = value * 10 + (c - '0');
}
return value;
}
}
@@ -0,0 +1,7 @@
package dev.relism.exceptions;
public class DuplicateNamespaceException extends RuntimeException {
public DuplicateNamespaceException(String namespace) {
super("Router with namespace '" + namespace + "' is already registered.");
}
}
@@ -0,0 +1,63 @@
package dev.relism.http;
import lombok.Getter;
import java.nio.charset.StandardCharsets;
/**
* Pre-compiled byte representations of common HTTP {@code Content-Type} values.
* {@link #getBytes()} returns the pre-computed array directly — never allocates.
*/
@Getter
public enum ContentType {
// Text
TEXT_PLAIN ("text/plain"),
TEXT_HTML ("text/html"),
TEXT_CSS ("text/css"),
TEXT_JAVASCRIPT ("text/javascript"),
TEXT_XML ("text/xml"),
TEXT_CSV ("text/csv"),
TEXT_MARKDOWN ("text/markdown"),
TEXT_EVENT_STREAM ("text/event-stream"),
// Application
JSON ("application/json"),
XML ("application/xml"),
BINARY ("application/octet-stream"),
PDF ("application/pdf"),
ZIP ("application/zip"),
GZIP ("application/gzip"),
FORM_URLENCODED ("application/x-www-form-urlencoded"),
MULTIPART_FORM ("multipart/form-data"),
GRAPHQL ("application/graphql"),
NDJSON ("application/x-ndjson"),
MSGPACK ("application/msgpack"),
CBOR ("application/cbor"),
LD_JSON ("application/ld+json"),
// Image
IMAGE_PNG ("image/png"),
IMAGE_JPEG ("image/jpeg"),
IMAGE_GIF ("image/gif"),
IMAGE_WEBP ("image/webp"),
IMAGE_SVG ("image/svg+xml"),
IMAGE_ICO ("image/x-icon"),
IMAGE_AVIF ("image/avif"),
// Font
FONT_WOFF ("font/woff"),
FONT_WOFF2 ("font/woff2"),
// Audio / Video
AUDIO_MPEG ("audio/mpeg"),
AUDIO_OGG ("audio/ogg"),
VIDEO_MP4 ("video/mp4"),
VIDEO_WEBM ("video/webm");
private final byte[] bytes;
ContentType(String value) {
this.bytes = value.getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,47 @@
package dev.relism.http;
import lombok.Getter;
import java.nio.charset.StandardCharsets;
@Getter
public enum HttpMethod {
GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, TRACE, CONNECT, PURGE;
private static final HttpMethod[] VALUES = values();
private final byte[] bytes;
HttpMethod() {
this.bytes = this.name().getBytes(StandardCharsets.UTF_8);
}
/**
* Resolves the HttpMethod from a byte buffer segment using O(1) dispatch.
* Extremely fast, zero allocations, branch-prediction friendly.
*/
public static HttpMethod fromBytes(byte[] buf, int off, int len) {
if (len == 0) return null;
return switch (buf[off]) {
case 'G' -> (len == 3 && buf[off + 1] == 'E' && buf[off + 2] == 'T') ? GET : null;
case 'P' -> {
if (len == 3 && buf[off + 1] == 'U' && buf[off + 2] == 'T') yield PUT;
if (len == 4 && buf[off + 1] == 'O' && buf[off + 2] == 'S' && buf[off + 3] == 'T') yield POST;
if (len == 5 && buf[off + 1] == 'A' && buf[off + 2] == 'T' && buf[off + 3] == 'C' && buf[off + 4] == 'H') yield PATCH;
if (len == 5 && buf[off + 1] == 'U' && buf[off + 2] == 'R' && buf[off + 3] == 'G' && buf[off + 4] == 'E') yield PURGE;
yield null;
}
case 'D' -> (len == 6 && buf[off + 1] == 'E' && buf[off + 2] == 'L' && buf[off + 3] == 'E' && buf[off + 4] == 'T' && buf[off + 5] == 'E') ? DELETE : null;
case 'O' -> (len == 7 && buf[off + 1] == 'P' && buf[off + 2] == 'T' && buf[off + 3] == 'I' && buf[off + 4] == 'O' && buf[off + 5] == 'N' && buf[off + 6] == 'S') ? OPTIONS : null;
case 'H' -> (len == 4 && buf[off + 1] == 'E' && buf[off + 2] == 'A' && buf[off + 3] == 'D') ? HEAD : null;
case 'T' -> (len == 5 && buf[off + 1] == 'R' && buf[off + 2] == 'A' && buf[off + 3] == 'C' && buf[off + 4] == 'E') ? TRACE : null;
case 'C' -> (len == 7 && buf[off + 1] == 'O' && buf[off + 2] == 'N' && buf[off + 3] == 'N' && buf[off + 4] == 'E' && buf[off + 5] == 'C' && buf[off + 6] == 'T') ? CONNECT : null;
default -> null;
};
}
//Only to be used for debugging/logging purposes, never in the hot path.
@Override
public String toString() {
return new String(bytes, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,78 @@
package dev.relism.http;
import java.nio.charset.StandardCharsets;
/**
* Pre-compiled byte representations of standard HTTP status lines.
* Uses a direct-access array for O(1) lookup with zero allocation.
*/
public enum HttpStatus {
// 1xx
CONTINUE (100, "Continue"),
SWITCHING_PROTOCOLS (101, "Switching Protocols"),
// 2xx
OK (200, "OK"),
CREATED (201, "Created"),
ACCEPTED (202, "Accepted"),
NO_CONTENT (204, "No Content"),
PARTIAL_CONTENT (206, "Partial Content"),
// 3xx
MOVED_PERMANENTLY (301, "Moved Permanently"),
FOUND (302, "Found"),
NOT_MODIFIED (304, "Not Modified"),
TEMPORARY_REDIRECT (307, "Temporary Redirect"),
PERMANENT_REDIRECT (308, "Permanent Redirect"),
// 4xx
BAD_REQUEST (400, "Bad Request"),
UNAUTHORIZED (401, "Unauthorized"),
FORBIDDEN (403, "Forbidden"),
NOT_FOUND (404, "Not Found"),
METHOD_NOT_ALLOWED (405, "Method Not Allowed"),
NOT_ACCEPTABLE (406, "Not Acceptable"),
CONFLICT (409, "Conflict"),
GONE (410, "Gone"),
LENGTH_REQUIRED (411, "Length Required"),
PAYLOAD_TOO_LARGE (413, "Payload Too Large"),
URI_TOO_LONG (414, "URI Too Long"),
UNSUPPORTED_MEDIA_TYPE (415, "Unsupported Media Type"),
UNPROCESSABLE_ENTITY (422, "Unprocessable Entity"),
TOO_MANY_REQUESTS (429, "Too Many Requests"),
// 5xx
INTERNAL_SERVER_ERROR (500, "Internal Server Error"),
NOT_IMPLEMENTED (501, "Not Implemented"),
BAD_GATEWAY (502, "Bad Gateway"),
SERVICE_UNAVAILABLE (503, "Service Unavailable"),
GATEWAY_TIMEOUT (504, "Gateway Timeout");
private static final int MAX_STATUS_CODE = 504;
private static final byte[][] INDEX = new byte[MAX_STATUS_CODE + 1][];
static {
for (HttpStatus s : values()) {
INDEX[s.code] = s.bytes;
}
}
private final int code;
private final byte[] bytes;
HttpStatus(int code, String reason) {
this.code = code;
this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8);
}
/** * Returns pre-compiled status bytes for the given code.
* Access is O(1) and generates zero garbage.
*/
public static byte[] bytesForCode(int code) {
if (code >= 0 && code <= MAX_STATUS_CODE) {
return INDEX[code];
}
return null;
}
}
@@ -0,0 +1,96 @@
package dev.relism.models;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
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.
*/
public class HeaderMap {
private static final int MAX_HEADERS = 32;
private final byte[] buffer;
private final int[] keys = new int[MAX_HEADERS * 2]; // interleaved [start, len] pairs
private final int[] values = new int[MAX_HEADERS * 2]; // interleaved [start, len] pairs
private int count = 0;
public HeaderMap(byte[] buffer) {
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;
keys[count * 2 + 1] = keyLen;
values[count * 2] = valStart;
values[count * 2 + 1] = valLen;
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++) {
if (keyMatches(i, name)) {
if (result == null) result = new ArrayList<>();
result.add(new String(buffer, values[i * 2], values[i * 2 + 1], StandardCharsets.UTF_8));
}
}
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++) {
result.add(new String(buffer, values[i * 2], values[i * 2 + 1], StandardCharsets.UTF_8));
}
return result;
}
/** Zero-copy: returns a ByteView over the raw value bytes without allocating a String. */
public ByteView getView(String name) {
int idx = indexOf(name);
if (idx < 0) return null;
final int start = values[idx * 2];
final int len = values[idx * 2 + 1];
return new ByteView() {
public int length() { return len; }
public byte byteAt(int i) { return buffer[start + i]; }
};
}
private int indexOf(String name) {
for (int i = 0; i < count; i++) {
if (keyMatches(i, name)) return i;
}
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;
for (int j = 0; j < kl; j++) {
byte b = buffer[ks + j];
if (b >= 'A' && b <= 'Z') b += 32;
char c = name.charAt(j);
if (c >= 'A' && c <= 'Z') c += 32;
if (b != (byte) c) return false;
}
return true;
}
}
@@ -0,0 +1,63 @@
package dev.relism.models;
import java.io.IOException;
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.
*/
final class LazyBody {
private static final byte[] EMPTY = new byte[0];
private final InputStream stream;
private final int contentLength;
private final byte[] preBuf;
private final int preBufOff;
private final int preBufLen;
private byte[] resolved;
LazyBody(InputStream stream, int contentLength, byte[] preBuf, int preBufOff, int preBufLen) {
this.stream = stream;
this.contentLength = contentLength;
this.preBuf = preBuf;
this.preBufOff = preBufOff;
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;
return lb;
}
byte[] get() {
if (resolved != null) return resolved;
byte[] buf = new byte[contentLength];
int copied = Math.min(preBufLen, contentLength);
if (copied > 0) System.arraycopy(preBuf, preBufOff, buf, 0, copied);
if (copied < contentLength) {
try {
stream.readNBytes(buf, copied, contentLength - copied);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
return resolved = buf;
}
}
@@ -0,0 +1,51 @@
package dev.relism.models;
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).
*/
public class PathParams {
private final ByteView source;
private final String[] names;
private final int[] starts;
private final int[] lens;
public PathParams(ByteView source, String[] names, int[] starts, int[] lens) {
this.source = source;
this.names = names;
this.starts = starts;
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;
byte[] bytes = new byte[lens[i]];
for (int j = 0; j < lens[i]; j++) bytes[j] = source.byteAt(starts[i] + j);
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;
final int s = starts[i], l = lens[i];
return new ByteView() {
public int length() { return l; }
public byte byteAt(int idx) { return source.byteAt(s + idx); }
};
}
private int indexOf(String name) {
for (int i = 0; i < names.length; i++) if (names[i].equals(name)) return i;
return -1;
}
}
@@ -0,0 +1,100 @@
package dev.relism.models;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
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).
*/
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;
public QueryParams(ByteView raw) {
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;
int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
byte[] bytes = new byte[l];
for (int i = 0; i < l; i++) bytes[i] = raw.byteAt(s + i);
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;
final int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
return new ByteView() {
public int length() { return l; }
public byte byteAt(int idx) { return raw.byteAt(s + idx); }
};
}
/** 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;
int i = 0, len = raw.length();
while (i < len) {
int keyStart = i;
while (i < len && raw.byteAt(i) != '=' && raw.byteAt(i) != '&') i++;
int keyLen = i - keyStart;
if (i < len && raw.byteAt(i) == '=') {
i++;
int valStart = i;
while (i < len && raw.byteAt(i) != '&') i++;
if (keyMatches(keyStart, keyLen, name)) {
int valLen = i - valStart;
byte[] bytes = new byte[valLen];
for (int j = 0; j < valLen; j++) bytes[j] = raw.byteAt(valStart + j);
if (result == null) result = new ArrayList<>();
result.add(new String(bytes, StandardCharsets.UTF_8));
}
}
if (i < len && raw.byteAt(i) == '&') i++;
}
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.
*/
private long findFirst(String name) {
if (raw == null) return -1L;
int i = 0, len = raw.length();
while (i < len) {
int keyStart = i;
while (i < len && raw.byteAt(i) != '=' && raw.byteAt(i) != '&') i++;
int keyLen = i - keyStart;
if (i < len && raw.byteAt(i) == '=') {
i++;
int valStart = i;
while (i < len && raw.byteAt(i) != '&') i++;
if (keyMatches(keyStart, keyLen, name)) return ((long) valStart << 32) | (i - valStart);
}
if (i < len && raw.byteAt(i) == '&') i++;
}
return -1L;
}
private boolean keyMatches(int start, int len, String name) {
if (len != name.length()) return false;
for (int i = 0; i < len; i++) if (raw.byteAt(start + i) != (byte) name.charAt(i)) return false;
return true;
}
}
@@ -0,0 +1,89 @@
package dev.relism.models;
import dev.relism.fpr.core.ByteView;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.Value;
import lombok.experimental.NonFinal;
import java.io.InputStream;
import java.util.List;
@Value
@ToString
public class Request {
RequestLine requestLine;
@Getter(lombok.AccessLevel.NONE)
@EqualsAndHashCode.Exclude
@ToString.Exclude
LazyBody lazyBody;
@NonFinal @Setter @Getter PathParams pathParams;
@NonFinal @Setter @Getter QueryParams queryParams;
private Request(RequestLine requestLine, LazyBody lazyBody) {
this.requestLine = requestLine;
this.lazyBody = lazyBody;
this.pathParams = null;
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) {
LazyBody lazy = contentLength > 0
? new LazyBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
: LazyBody.empty();
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(); }
// --- 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);
}
private QueryParams resolveQueryParams() {
if (queryParams == null) {
ByteView raw = requestLine.getQuery();
queryParams = raw != null ? new QueryParams(raw) : QueryParams.EMPTY;
}
return queryParams;
}
}
@@ -0,0 +1,17 @@
package dev.relism.models;
/**
* Base class for class-based route handlers.
*
* <p>Annotate the subclass with {@link dev.relism.routing.Route @Route} and register it
* via {@link dev.relism.routing.AbstractRouter#register}. For one-off routes, prefer the
* lambda DSL ({@code server.get(path, handler)}) which wraps a {@link SimpleHandler} internally.
*/
public abstract class RequestHandler {
/**
* Handles an incoming request. The return value determines the response body:
* return a {@link Response} to replace the whole response, any other non-null value
* to set it as the body, or {@code null} to leave the response as-is.
*/
public abstract Object handle(Request request, Response response);
}
@@ -0,0 +1,17 @@
package dev.relism.models;
import dev.relism.fpr.core.ByteView;
import dev.relism.http.HttpMethod;
import lombok.ToString;
import lombok.Value;
@ToString
@Value
public class RequestLine {
HttpMethod method;
ByteView path;
/** Raw query string bytes (after {@code ?}), {@code null} if the URI has no query string. */
ByteView query;
ByteView protocol;
HeaderMap headers;
}
@@ -0,0 +1,45 @@
package dev.relism.models;
import dev.relism.http.ContentType;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.nio.charset.StandardCharsets;
@Getter
@ToString
public class Response {
@Setter private int statusCode;
private byte[] body;
private byte[] contentType;
public Response(int statusCode, byte[] body, ContentType contentType) {
this.statusCode = statusCode;
this.body = body;
this.contentType = contentType.getBytes();
}
public Response(int statusCode, String text, ContentType contentType) {
this.statusCode = statusCode;
this.body = text.getBytes(StandardCharsets.UTF_8);
this.contentType = contentType.getBytes();
}
public void setContentType(ContentType contentType) {
this.contentType = contentType.getBytes();
}
public void setContentType(String contentType) {
this.contentType = contentType.getBytes(StandardCharsets.UTF_8);
}
public Response setBody(Object body) {
if (body instanceof byte[] bytes) {
this.body = bytes;
} else if (body != null) {
this.body = body.toString().getBytes(StandardCharsets.UTF_8);
}
return this;
}
}
@@ -0,0 +1,26 @@
package dev.relism.models;
import lombok.RequiredArgsConstructor;
/**
* A concrete implementation of {@link RequestHandler} that delegates to a functional interface.
* This allows the use of lambdas while keeping the base {@link RequestHandler} as an abstract class.
*/
@RequiredArgsConstructor
public class SimpleHandler extends RequestHandler {
private final FunctionalHandler delegate;
@Override
public Object handle(Request request, Response response) {
return delegate.handle(request, response);
}
/**
* Functional interface for handling requests, used by {@link SimpleHandler}.
*/
@FunctionalInterface
public interface FunctionalHandler {
Object handle(Request request, Response response);
}
}
@@ -0,0 +1,133 @@
package dev.relism.routing;
import dev.relism.fpr.core.ByteView;
import dev.relism.http.ContentType;
import dev.relism.http.HttpMethod;
import dev.relism.models.*;
import dev.relism.template.ErrorPages;
import lombok.AccessLevel;
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.
*/
public abstract class AbstractRouter {
@Getter
protected String namespace = "/";
@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);
return ErrorPages.renderNotFound(req);
});
protected ExceptionHandler exceptionHandler = (ex, req, res) -> {
res.setStatusCode(500);
res.setContentType(ContentType.TEXT_HTML);
return ErrorPages.renderException(req, ex);
};
// Package-private — used by GlobalRouter only
SimpleHandler getNotFoundHandler() { return notFoundHandler; }
ExceptionHandler getExceptionHandler() { return exceptionHandler; }
void setNamespace(String namespace) {
this.namespace = namespace;
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>
*/
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.
*/
public AbstractRouter register(RequestHandler handler) {
Route annotation = handler.getClass().getAnnotation(Route.class);
if (annotation != null) {
addRoute(HttpMethod.valueOf(annotation.method()), annotation.path(), handler);
}
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));
}
@FunctionalInterface
public interface ExceptionHandler {
Object handle(Exception exception, Request request, Response response);
}
}
@@ -0,0 +1,77 @@
package dev.relism.routing;
import dev.relism.exceptions.DuplicateNamespaceException;
import dev.relism.fpr.core.ByteView;
import dev.relism.http.HttpMethod;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
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}.
*/
public class GlobalRouter extends AbstractRouter {
private final Map<String, AbstractRouter> subRoutersMap = new HashMap<>();
private final List<AbstractRouter> sortedSubRouters = new ArrayList<>();
private final AbstractRouter internalRouter = new FastPathRouterImpl();
public void mount(String namespace, AbstractRouter router) {
String sanitized = PathUtils.sanitize(namespace);
if (subRoutersMap.containsKey(sanitized)) throw new DuplicateNamespaceException(sanitized);
router.setNamespace(sanitized);
subRoutersMap.put(sanitized, router);
sortedSubRouters.add(router);
sortedSubRouters.sort(Comparator.comparingInt((AbstractRouter r) -> r.getNamespaceBytes().length).reversed());
}
@Override
public RequestHandler route(Request request) {
ByteView path = request.getRequestLine().getPath();
for (AbstractRouter sub : sortedSubRouters) {
if (startsWith(path, sub.getNamespaceBytes())) {
RequestHandler h = sub.route(request);
return h != null ? h : sub.getNotFoundHandler();
}
}
RequestHandler h = internalRouter.route(request);
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) {
if (startsWith(path, sub.getNamespaceBytes())) return sub.getExceptionHandler();
}
return exceptionHandler;
}
@Override
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
return internalRouter.addRoute(method, PathUtils.sanitize(path), handler);
}
private static boolean startsWith(ByteView view, byte[] prefix) {
if (view.length() < prefix.length) return false;
for (int i = 0; i < prefix.length; i++) {
if (view.byteAt(i) != prefix[i]) return false;
}
return true;
}
}
@@ -0,0 +1,47 @@
package dev.relism.routing;
public class PathUtils {
/**
* Sanitizes a path segment to ensure it starts with '/' and has no trailing slash.
* Replaces multiple slashes with a single one.
*/
public static String sanitize(String path) {
if (path == null || path.isBlank() || path.trim().equals("/")) {
return "/";
}
String sanitized = path.trim().replaceAll("/{2,}", "/");
if (!sanitized.startsWith("/")) {
sanitized = "/" + sanitized;
}
if (sanitized.length() > 1 && sanitized.endsWith("/")) {
sanitized = sanitized.substring(0, sanitized.length() - 1);
}
return sanitized;
}
/**
* Joins two path segments and ensures the result is sanitized.
* Prevents "double namespace" if the path already starts with the base.
*/
public static String join(String base, String path) {
String sBase = sanitize(base);
String sPath = sanitize(path);
if (sBase.equals("/")) return sPath;
if (sPath.equals("/") || sPath.isEmpty()) return sBase;
// If sPath already starts with sBase, don't prepend it again
// Example: base="/api", path="/api/users" -> "/api/users"
if (sPath.startsWith(sBase)) {
return sPath;
}
// Otherwise, prepend base to path
String joined = sBase + (sPath.startsWith("/") ? sPath : "/" + sPath);
return sanitize(joined);
}
}
@@ -0,0 +1,22 @@
package dev.relism.routing;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Declares the HTTP method and path for a class-based {@link dev.relism.models.RequestHandler}.
*
* <p>The path is relative to the namespace of the router the handler is registered on.
* For example, registering a handler with {@code path = "/profile"} on a router mounted
* at {@code /api} results in the effective route {@code /api/profile}.
*
* <p>Used by {@link dev.relism.routing.AbstractRouter#register}.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Route {
String method() default "GET";
String path();
}
@@ -0,0 +1,114 @@
package dev.relism.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.http.HttpMethod;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
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.
*/
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));
private static final ThreadLocal<FastPathViews.MethodPathByteView> COMBINED_VIEW_HOLDER =
ThreadLocal.withInitial(FastPathViews.MethodPathByteView::new);
public static MatchResult<RequestHandler> getResult() {
return RESULT_HOLDER.get();
}
public static FastPathViews.MethodPathByteView getCombinedView() {
return COMBINED_VIEW_HOLDER.get();
}
}
@Override
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
return this;
}
@Override
public RequestHandler route(Request request) {
ensureCompiled();
MatchResult<RequestHandler> result = FastPathRouterContext.getResult();
result.reset();
HttpMethod method = request.getRequestLine().getMethod();
ByteView pathView = request.getRequestLine().getPath();
FastPathViews.MethodPathByteView combinedView = FastPathRouterContext.getCombinedView();
combinedView.reset(method.getBytes(), pathView);
int labelId = router.match(combinedView, result);
if (labelId == FastPathRouter.NO_MATCH) {
return null;
}
// CAPTURE PARAMETERS
int count = result.paramCount();
if (count > 0) {
int methodLen = method.getBytes().length;
String[] all = cachedParamNames;
String[] names = new String[count];
int[] starts = new int[count];
int[] lens = new int[count];
for (int i = 0; i < count; i++) {
names[i] = all[result.keyIdAt(i)];
starts[i] = result.startAt(i) - methodLen;
lens[i] = result.lenAt(i);
}
setPathParams(request, names, pathView, starts, lens);
}
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) {
if (router == null) {
cachedParamNames = builder.paramNames(); // written before volatile router
router = builder.compile(); // volatile write: establishes happens-before
}
}
}
}
}
@@ -0,0 +1,120 @@
package dev.relism.routing.routers.fastpathrouter;
import dev.relism.fpr.core.ByteView;
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.
*/
@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;
private final int length;
public RequestByteView(byte[] buffer, int start, int length) {
this.buffer = buffer;
this.start = start;
this.length = length;
}
@Override
public int length() {
return length;
}
@Override
public byte byteAt(int index) {
if (index < 0 || index >= length) {
throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + length);
}
return buffer[start + index];
}
@Override
public String toString() { // CHANGED: decode slice directly; avoids full-buffer String copy
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.
*/
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;
this.totalLength = method.length + path.length();
}
@Override
public int length() {
return totalLength;
}
@Override
public byte byteAt(int index) {
return index < method.length ? method[index] : path.byteAt(index - method.length);
}
}
/**
* ByteView implementation for raw byte arrays, typically from a socket.
*/
public static class SocketByteView implements ByteView {
private final byte[] data;
public SocketByteView(byte[] data) {
this.data = data;
}
@Override
public int length() {
return data.length;
}
@Override
public byte byteAt(int index) {
return data[index];
}
}
/**
* ByteView implementation for Java Strings.
*/
public static class StringByteView implements ByteView {
private final byte[] bytes;
public StringByteView(String str) {
this.bytes = str.getBytes(StandardCharsets.UTF_8);
}
@Override
public int length() {
return bytes.length;
}
@Override
public byte byteAt(int index) {
return bytes[index];
}
}
}
@@ -0,0 +1,5 @@
package dev.relism.routing.routers.radix;
public class RadixPathRouterImpl {
}
@@ -0,0 +1,75 @@
package dev.relism.template;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* Precompiled, allocation-minimal byte template.
* <p>
* Placeholders of the form {@code {{name}}} are detected once at construction.
* Each {@link #render} call makes exactly one allocation: the output byte[].
* <p>
* Layout: seg[0] slot[0] seg[1] slot[1] … seg[n-1] slot[n-1] seg[n]
*/
public final class ByteTemplate {
private final byte[][] segments; // literal byte segments
private final String[] slots; // placeholder names in order
private final int staticLength; // sum of all segment lengths (precomputed)
public ByteTemplate(String source) {
List<byte[]> segs = new ArrayList<>();
List<String> slts = new ArrayList<>();
int start = 0, i = 0;
while (i < source.length()) {
if (source.charAt(i) == '{' && i + 1 < source.length() && source.charAt(i + 1) == '{') {
int end = source.indexOf("}}", i + 2);
if (end < 0) break;
segs.add(source.substring(start, i).getBytes(StandardCharsets.UTF_8));
slts.add(source.substring(i + 2, end));
start = end + 2;
i = end + 2;
} else {
i++;
}
}
segs.add(source.substring(start).getBytes(StandardCharsets.UTF_8));
segments = segs.toArray(new byte[0][]);
slots = slts.toArray(new String[0]);
int sl = 0;
for (byte[] s : segments) sl += s.length;
staticLength = sl;
}
/**
* Render with alternating key-value String pairs: {@code k1, v1, k2, v2, …}
* Unmatched slots are rendered as empty.
*/
public byte[] render(String... kvPairs) {
byte[][] values = new byte[slots.length][];
for (int i = 0; i + 1 < kvPairs.length; i += 2) {
String key = kvPairs[i];
byte[] val = kvPairs[i + 1].getBytes(StandardCharsets.UTF_8);
for (int j = 0; j < slots.length; j++) {
if (slots[j].equals(key)) { values[j] = val; }
}
}
int len = staticLength;
for (byte[] v : values) if (v != null) len += v.length;
byte[] out = new byte[len];
int pos = 0;
for (int i = 0; i < slots.length; i++) {
System.arraycopy(segments[i], 0, out, pos, segments[i].length);
pos += segments[i].length;
if (values[i] != null) {
System.arraycopy(values[i], 0, out, pos, values[i].length);
pos += values[i].length;
}
}
System.arraycopy(segments[slots.length], 0, out, pos, segments[slots.length].length);
return out;
}
}
@@ -0,0 +1,87 @@
package dev.relism.template;
import dev.relism.Flash;
import dev.relism.models.Request;
import dev.relism.models.RequestLine;
import lombok.NoArgsConstructor;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
/**
* Precompiled error page templates.
* <p>
* Static placeholders ({@code logo_img}, {@code version}) are baked in once at
* class init. Dynamic placeholders (method, path, exception …) are filled per
* call with a single allocation via {@link ByteTemplate#render}.
*/
@NoArgsConstructor
public final class ErrorPages {
private static final ByteTemplate TEMPLATE_404;
private static final ByteTemplate TEMPLATE_500;
static {
String logo = loadLogoImg();
TEMPLATE_404 = bake("assets/html/default_404.html", logo);
TEMPLATE_500 = bake("assets/html/default_exception.html", logo);
}
public static byte[] renderNotFound(Request request) {
RequestLine rl = request.getRequestLine();
return TEMPLATE_404.render(
"method", rl.getMethod().toString(),
"path", rl.getPath().toString(),
"protocol", rl.getProtocol().toString(),
"timestamp", Instant.now().toString()
);
}
public static byte[] renderException(Request request, Exception ex) {
RequestLine rl = request.getRequestLine();
StringWriter sw = new StringWriter();
ex.printStackTrace(new PrintWriter(sw));
return TEMPLATE_500.render(
"method", rl.getMethod().toString(),
"path", rl.getPath().toString(),
"protocol", rl.getProtocol().toString(),
"exception_type", ex.getClass().getName(),
"exception_message", ex.getMessage() != null ? ex.getMessage() : "",
"stacktrace", sw.toString(),
"timestamp", Instant.now().toString()
);
}
// --- init helpers ---
private static ByteTemplate bake(String resource, String logoHtml) {
String raw = load(resource)
.replace("{{logo_img}}", logoHtml)
.replace("{{version}}", Flash.VERSION);
return new ByteTemplate(raw);
}
private static String load(String resource) {
try (InputStream is = ErrorPages.class.getClassLoader().getResourceAsStream(resource)) {
if (is == null) throw new RuntimeException("Missing resource: " + resource);
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException("Failed to load: " + resource, e);
}
}
private static String loadLogoImg() {
try (InputStream is = ErrorPages.class.getClassLoader().getResourceAsStream("assets/logo.png")) {
if (is == null) return "";
String b64 = Base64.getEncoder().encodeToString(is.readAllBytes());
return "<img class=\"footer-logo\" src=\"data:image/png;base64," + b64 + "\" alt=\"Flash\">";
} catch (IOException e) {
return "";
}
}
}