diff --git a/README.md b/README.md index f7f25c1..c912e82 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,83 @@ -# Flash Benchmarking Module +# Flash -This module is designed for high-concurrency benchmarks using `wrk`. +A high-performance HTTP/1.1 server library for Java 21+, built for low-latency workloads. + +## Features + +- **Virtual threads** — one virtual thread per connection via `Executors.newVirtualThreadPerTaskExecutor()` +- **Zero-allocation hot path** — header buffer reused across keep-alive requests; `ByteView` slices avoid copies +- **Fast routing** — `fpr-core` compiles routes into a byte-level finite automaton at first request; path params via `PathParams` +- **Two-tier router** — mounted sub-routers matched by longest prefix, then `FastPathRouterImpl` fallback +- **Middleware chain** — composable `Middleware` lambdas fused at registration time (no per-request allocation) +- **Chunked transfer** — `ChunkedInputStream` de-chunks on the fly, keeps socket positioned for pipelining +- **Request body** — `bytes()` for full materialization or `stream()` for zero-copy streaming + +## Requirements -## Prerequisite - Java 21+ -- `wrk` installed (`sudo apt install wrk` on Linux/WSL) +- Maven 3.8+ -## Running the benchmarks +## Build -1. **Build the project** (from the root directory): - ```bash - mvn clean package -DskipTests - ``` - -2. **Start the benchmark server**: - ```bash - java -jar flash-bench/target/flash-bench-1.0-SNAPSHOT.jar - ``` - -3. **Execute the benchmark suite** (from a new terminal): - ```bash - cd flash-bench - ./benchmark.sh - ``` - -### Advanced: Random IDs Benchmarking -To test the speed of parameter extraction across millions of unique IDs: ```bash -wrk -t12 -c500 -d30s -s pipeline.lua http://127.0.0.1:8080 +mvn clean package -DskipTests +``` + +## Quick start + +```java +HttpServer server = new HttpServer( + HttpServerConfiguration.builder().port(8080).build() +); + +server.get("/hello", (req, res) -> "Hello, world!"); + +server.register(new MyHandler()); // @Route(path="/...", method=GET) + +server.start().thenRun(() -> System.out.println("Listening on :8080")); +``` + +## Handler styles + +**Lambda:** +```java +server.get("/ping", (req, res) -> "pong"); +``` + +**Class-based (`@Route`):** +```java +@Route(path = "/users/{id}", method = HttpMethod.GET) +public class GetUser extends RequestHandler { + @Override + public Object handle(Request req, Response res) { + String id = req.pathParam("id"); + return "{\"id\":\"" + id + "\"}"; + } +} +``` + +**Middleware:** +```java +Middleware cors = next -> (req, res) -> { + res.header("Access-Control-Allow-Origin", "*"); + return next.handle(req, res); +}; +server.register(new GetUser(), cors); +``` + +## Maven dependency + +```xml + + + relism-releases + https://maven.relism.dev/releases + + + + + dev.relism + flash + 1.0-SNAPSHOT + ``` diff --git a/dev/relism/fpr/core/FastPathRouter.class b/dev/relism/fpr/core/FastPathRouter.class deleted file mode 100644 index 73c7291..0000000 Binary files a/dev/relism/fpr/core/FastPathRouter.class and /dev/null differ diff --git a/dev/relism/fpr/core/internal/runtime/FrozenRouter.class b/dev/relism/fpr/core/internal/runtime/FrozenRouter.class deleted file mode 100644 index 217a6ca..0000000 Binary files a/dev/relism/fpr/core/internal/runtime/FrozenRouter.class and /dev/null differ diff --git a/dev/relism/fpr/core/internal/runtime/RouteSearch.class b/dev/relism/fpr/core/internal/runtime/RouteSearch.class deleted file mode 100644 index 98f1203..0000000 Binary files a/dev/relism/fpr/core/internal/runtime/RouteSearch.class and /dev/null differ diff --git a/dev/relism/fpr/core/internal/runtime/SegmentCursor.class b/dev/relism/fpr/core/internal/runtime/SegmentCursor.class deleted file mode 100644 index dfcf53b..0000000 Binary files a/dev/relism/fpr/core/internal/runtime/SegmentCursor.class and /dev/null differ diff --git a/docs/lifecycle/request/BODY.md b/docs/lifecycle/request/BODY.md deleted file mode 100644 index 30a5770..0000000 --- a/docs/lifecycle/request/BODY.md +++ /dev/null @@ -1,98 +0,0 @@ -# Request Body - -`req.body()` returns a `RequestBody` — a lazy accessor that exposes the HTTP request body in -two mutually exclusive modes. Choose one per handler based on the payload size and how you -intend to consume it. - ---- - -## Two modes - -### `bytes()` — materialise - -```java -byte[] body = req.body().bytes(); -``` - -Reads the full body into a `byte[]` and caches the result. Safe to call multiple times — the -second call returns the same cached array, no I/O is performed again. - -Use for: -- JSON parsing (`new String(body, UTF_8)` then parse) -- Small form data -- Any payload that must be inspected in full before responding - -**Limit:** throws `IllegalStateException` if `Content-Length` exceeds `Integer.MAX_VALUE` -(~2 GB). For larger bodies use `stream()`. - -**Chunked bodies:** reads until `ChunkedInputStream` signals EOF, then caches. - ---- - -### `stream()` — zero-copy - -```java -InputStream in = req.body().stream(); -``` - -Returns a bounded `InputStream` without allocating the full body upfront. - -Use for: -- Large file uploads -- Bodies passed directly to disk, a database, or another stream -- Multipart parsing (via `Multipart.of(req)` which calls this internally) - -**Fixed-length bodies:** a `SequenceInputStream` of any pre-buffered header bytes (from the -`RequestParser` read buffer, already in memory) followed by a bounded view of the socket -stream. The small pre-buffered slice costs nothing extra. - -**Chunked bodies:** returns the raw `ChunkedInputStream` directly. It de-chunks on the fly — -reads the hex chunk-size line, the data, and the trailing CRLF — and signals EOF at the end -of the last chunk, leaving the socket positioned correctly for the next keep-alive request. - -**After `bytes()`:** if the body was already materialised, `stream()` returns a fresh -`ByteArrayInputStream` over the cached array. No I/O is performed. - ---- - -## Mutual exclusivity - -```java -// SAFE — pick one mode -byte[] b = req.body().bytes(); -InputStream s = req.body().stream(); - -// UNSAFE — bytes() then stream() is safe (stream() wraps the cache) -// UNSAFE — stream() partially read, then bytes() → undefined results -``` - -The rule: if you call `stream()` first and partially read it, calling `bytes()` afterwards -will either throw (if `contentLength` > 2 GB) or read only the remaining socket bytes into -the array, missing the already-consumed portion. - ---- - -## Checking the size - -```java -long size = req.body().contentLength(); -``` - -- **> 0** — fixed-length body; exact byte count -- **== 0** — empty body (`Content-Length: 0` or no body present) -- **== -1** — `Transfer-Encoding: chunked`; size is unknown upfront - -```java -boolean empty = req.body().isEmpty(); // true when contentLength == 0 -``` - ---- - -## Decision guide - -| Payload size | Encoding | Use | -|--------------------|-----------|----------------| -| < 2 GB, fully needed | any | `bytes()` | -| Any size, pass-through | any | `stream()` | -| Multipart form | any | `Multipart.of(req)` (uses `stream()` internally) | -| Size unknown | chunked | `stream()` | diff --git a/docs/lifecycle/request/MULTIPART.md b/docs/lifecycle/request/MULTIPART.md deleted file mode 100644 index 1e15294..0000000 --- a/docs/lifecycle/request/MULTIPART.md +++ /dev/null @@ -1,190 +0,0 @@ -# Multipart - -`Multipart` is a lazy streaming `multipart/form-data` parser. It reads from `req.body().stream()` -— the request body is **never fully materialised**. Text fields are buffered eagerly (they are -small by definition); file part bodies are exposed as zero-copy `InputStream`s backed directly -by the socket. - ---- - -## Creating a parser - -```java -Multipart mp = Multipart.of(req); -``` - -Throws `IllegalArgumentException` if the request is not `multipart/form-data` or the -`boundary` parameter is missing. Throws `IOException` if the initial stream read fails (the -parser skips the opening `--boundary\r\n` preamble on construction). - ---- - -## Reading text fields — `field(String name)` - -```java -String userId = mp.field("userId"); // null if absent or a file part -String width = mp.field("width"); -``` - -Scans forward through the stream until the named text field is found. Text parts encountered -along the way are buffered eagerly. File bodies encountered along the way are **drained -silently** (discarded without heap allocation). - -**Order-independent for text:** once a text field has been scanned, it is cached. Calling -`mp.field("userId")` after `mp.field("width")` will hit the cache if `userId` was encountered -first in the stream. - -Returns `null` if the field is absent or if the named part is a file (has a `filename` -attribute). Never returns file content. - ---- - -## Reading file parts — `file(String name)` - -```java -Part avatar = mp.file("avatar"); // null if absent or a text field -``` - -Scans forward until the named file part is found. Text parts encountered along the way are -buffered (accessible later via `field()`). Earlier file bodies encountered along the way are -drained silently. - -The returned `Part`'s stream is backed by the socket. **Consume it before calling any other -scan method** — calling `mp.field()`, `mp.file()`, or `mp.parts()` will drain the active -stream automatically, but any bytes already read are gone. - -Returns `null` if absent or if the named part is a text field. - ---- - -## Collecting multiple parts — `parts(String name)` and `parts()` - -```java -List files = mp.parts("files"); // all parts named "files" -List all = mp.parts(); // every part in declaration order -``` - -Forces a **full scan**. All file bodies are materialised into `byte[]` arrays — accept this -heap cost explicitly when calling these methods. Useful for bulk uploads where you need all -files before processing any of them. - -`parts()` must be called before any `file()` call if you need all file parts, since a previous -`file()` call may have consumed some of them from the stream. - ---- - -## The `Part` object - -Every part — whether text or file — is returned as a `Part`. - -### Metadata - -```java -part.name() // field name from Content-Disposition -part.filename() // original filename, null for text fields -part.contentType() // declared Content-Type, null if not present -part.isFile() // true when filename() != null -``` - -### Body access - -```java -// Zero-copy stream — the default -InputStream in = part.stream(); - -// Explicit materialisation — opt-in heap allocation -byte[] data = part.materialize(); - -// UTF-8 convenience — materialises if needed, result is cached -String text = part.text(); -``` - -#### `stream()` - -For **buffered parts** (text fields and `parts()` results): returns a fresh -`ByteArrayInputStream` over the cached `byte[]`. Repeatable, no I/O. - -For **streaming file parts** (from `file()`): returns the raw bounded socket `InputStream`. -**Read it once only**, and before requesting the next part. - -#### `materialize()` - -Reads the full part body into a `byte[]` and caches it. Safe to call multiple times on -buffered parts (returns the same array). For streaming file parts, triggers a full socket read -on the first call; subsequent calls return the cached array. - -#### `text()` - -Convenience over `materialize()`. Decodes the body as UTF-8. Result is cached. - ---- - -## Memory model - -| Operation | Heap cost | -|---|---| -| `Multipart.of(req)` | 8 KB window buffer + boundary bytes | -| `mp.field("x")` | body of the text field only (~bytes of the value) | -| `mp.file("f")` | none — body is a bounded socket stream | -| `part.stream()` on a file | none | -| `part.materialize()` on a file | full file size | -| `mp.parts()` | full body of every part | -| Draining a file body (implicit) | 8 KB reused drain buffer | - ---- - -## Ordering constraints and safe patterns - -### Pattern 1 — text fields + one file (most common) - -Text fields are accessible in any order. The file is streamed zero-copy. - -```java -Multipart mp = Multipart.of(req); -String userId = mp.field("userId"); // order doesn't matter for text -String label = mp.field("label"); -Part file = mp.file("file"); // stream it after fields - -Files.copy(file.stream(), destination, StandardCopyOption.REPLACE_EXISTING); -return res.body("saved " + file.filename()); -``` - -### Pattern 2 — echo large file (zero heap) - -```java -Part file = Multipart.of(req).file("file"); -return res.chunked(file.stream()); // socket-in → chunked-out, never touches heap -``` - -### Pattern 3 — bulk upload (accept the heap cost) - -```java -List files = Multipart.of(req).parts("files"); -for (Part f : files) { - Files.write(dir.resolve(f.filename()), f.materialize()); -} -``` - -### Pattern 4 — mixed order (file before text in the form) - -If the form declares the file before a text field and you need the text first, -`field()` will drain the file body silently to reach the text part. The file is then gone. -Either reorder the form fields or use `parts()` if you need both. - -```java -// Form order: file, then userId -// This works — file is drained silently, userId is buffered -String userId = mp.field("userId"); - -// But now mp.file("file") returns null — already drained -``` - -If you need both, use `parts()` upfront: - -```java -List all = mp.parts(); -Part file = all.stream().filter(Part::isFile).findFirst().orElse(null); -String userId = all.stream().filter(p -> "userId".equals(p.name())).findFirst() - .map(p -> { try { return p.text(); } catch (IOException e) { throw new UncheckedIOException(e); } }) - .orElse(null); -``` diff --git a/docs/lifecycle/request/REQUEST.md b/docs/lifecycle/request/REQUEST.md deleted file mode 100644 index b99d289..0000000 --- a/docs/lifecycle/request/REQUEST.md +++ /dev/null @@ -1,104 +0,0 @@ -# Request - -The `Request` object is the immutable view of an incoming HTTP/1.1 request passed to every -handler. It is constructed by `RequestParser`, path/query parameters are injected once by the -router, and then it is handed to your handler unchanged. - -```java -server.get("/users/{id}", (req, res) -> { ... }); -``` - ---- - -## Method and path - -```java -HttpMethod method = req.method(); // GET, POST, PUT, … -String path = req.path(); // "/users/42" — decoded UTF-8, no query string -``` - -`path()` allocates a `String` by decoding the underlying `ByteView`. It is a convenience for -handlers that need the raw path; the router itself never calls it (it works on the `ByteView` -directly for zero-alloc routing). - ---- - -## Headers - -```java -String ct = req.header("Content-Type"); // first value, or null -List accepts = req.headers("Accept"); // all values in order -List all = req.headers(); // every header value -``` - -Lookup is **case-insensitive**: `"content-type"` and `"Content-Type"` resolve to the same header. -`header()` returns `null` if the header is absent. `headers()` returns an empty list. - ---- - -## Path parameters - -Path parameters are declared in the route pattern with `{name}` syntax and injected by the -router before the handler runs. - -```java -server.get("/users/{id}/orders/{orderId}", (req, res) -> { - String id = req.param("id"); - String orderId = req.param("orderId"); -}); -``` - -`param()` returns `null` if the route has no such parameter or is not parametric at all. -It never throws. - ---- - -## Query parameters - -The query string is parsed lazily on the first `query()` or `queries()` call and cached for -the lifetime of the request. - -```java -// GET /search?q=flash&page=2&tag=java&tag=jvm - -String q = req.query("q"); // "flash" -String page = req.query("page"); // "2" -List tags = req.queries("tag"); // ["java", "jvm"] -String miss = req.query("absent"); // null -``` - -`query()` returns the first value for duplicate keys. `queries()` returns all values in -declaration order. Both return `null` / empty list for absent parameters, never throw. - ---- - -## Body - -See [BODY.md](BODY.md) for a full reference. Quick summary: - -```java -// Small payloads — materialise into byte[] -byte[] json = req.body().bytes(); - -// Large payloads — stream without heap allocation -InputStream in = req.body().stream(); - -// Check size before choosing -long size = req.body().contentLength(); // -1 if Transfer-Encoding: chunked -``` - -The two modes are **mutually exclusive per request**. Calling `stream()` after `bytes()` returns -a `ByteArrayInputStream` over the cached array; calling `bytes()` after `stream()` has been -partially read produces undefined results. - ---- - -## Keep-alive and drain - -After the response is fully written to the socket, the server calls `req.drain()` automatically -on keep-alive connections. This discards any unread body bytes so the socket is correctly -positioned for the next pipelined request. You never need to call this yourself. - -**Important:** drain happens *after* the response write, not immediately after the handler -returns. This matters for streaming responses that consume the request body on a separate thread -(e.g. a pipe-fed echo): by the time drain runs, the body has already been consumed. diff --git a/docs/lifecycle/response/RESPONSE.md b/docs/lifecycle/response/RESPONSE.md deleted file mode 100644 index 4ef0c7e..0000000 --- a/docs/lifecycle/response/RESPONSE.md +++ /dev/null @@ -1,123 +0,0 @@ -# Response - -The `Response` object is the mutable view of an outgoing HTTP/1.1 response. It is -pre-initialised by the server (`200 / text/plain`) and passed to every handler as `res`. -All mutating methods return `this` for fluent chaining. - -```java -server.get("/hello", (req, res) -> res.body("hello")); -server.post("/items", (req, res) -> res.status(HttpStatus.CREATED).body("created")); -``` - ---- - -## Status code - -```java -res.status(404) // int overload — phrase looked up via HttpStatus.bytesForCode() -res.status(HttpStatus.NOT_FOUND) // enum overload — pre-encoded bytes written directly, zero lookup -``` - -Prefer the `HttpStatus` overload on hot paths: the pre-encoded `byte[]` is written to the socket -without any lookup. The `int` overload falls back to an O(1) array lookup — still zero allocation, -just one extra indirection. - ---- - -## Content type - -```java -res.type(ContentType.JSON) // enum — pre-encoded bytes, zero allocation -res.type("application/octet-stream") // String — encoded once at call time -``` - -Same pattern as `status`: the `ContentType` enum carries pre-encoded bytes; the `String` overload -encodes once and stores the result. - ---- - -## Body modes - -Three mutually exclusive modes. The last one set wins. - -### Fixed body - -```java -res.body("hello") // String → UTF-8 bytes -res.body(new byte[]{...}) // raw bytes -``` - -Written with `Content-Length`. The byte array is stored as-is; no copy on the write path. - -### Known-length stream - -```java -res.stream(Files.newInputStream(path), Files.size(path)) -``` - -Written with `Content-Length`. The stream is drained directly to the socket in the write path — -zero intermediate buffer beyond the OS socket buffer. - -### Unknown-length stream (chunked) - -```java -res.chunked(source) -``` - -Written with `Transfer-Encoding: chunked`. The server reads 8 KB at a time from `source`, -writes each chunk with its hex-encoded length, and closes with the terminal `0\r\n\r\n`. -No `Content-Length` header is emitted. - -Use when the body length is not known upfront: generated content, pipe-fed streams, proxied -responses. - ---- - -## Custom headers - -```java -res.header("X-Request-Id", "abc123") - .header("Cache-Control", "no-store") -``` - -Each header is encoded as `"Name: Value\r\n"` bytes **at call time** and stored in a list. -On the write path the pre-encoded arrays are written directly — zero allocation per request. - ---- - -## Fluent chaining - -All mutating methods return `this`. A complete response in one expression: - -```java -return res.status(HttpStatus.CREATED) - .type(ContentType.JSON) - .header("X-Trace-Id", traceId) - .body(json); -``` - ---- - -## Body mode decision guide - -| Payload | Mode | Header emitted | -|----------------------------------|-------------------------------|-------------------------| -| Small / fully known | `body()` | `Content-Length` | -| File or stream with known size | `stream(is, length)` | `Content-Length` | -| Generated / piped / unknown size | `chunked(is)` | `Transfer-Encoding: chunked` | - ---- - -## Memory model - -| Call | Allocation | -|-----------------------------|-------------------------------------------------| -| `status(HttpStatus)` | none — writes pre-encoded `byte[]` from enum | -| `status(int)` | none — O(1) array lookup at write time | -| `type(ContentType)` | none — pre-encoded `byte[]` from enum | -| `type(String)` | encodes once at call time, stored as `byte[]` | -| `body(String)` | one UTF-8 encode at call time | -| `body(byte[])` | none — array stored by reference | -| `stream(is, len)` | none — stream drained directly to socket | -| `chunked(is)` | 8 KB read buffer per write call (stack-local) | -| `header(name, value)` | one UTF-8 encode at call time per header | diff --git a/flash-bench/dependency-reduced-pom.xml b/flash-bench/dependency-reduced-pom.xml deleted file mode 100644 index 01864f1..0000000 --- a/flash-bench/dependency-reduced-pom.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - flash-parent - dev.relism - 1.0-SNAPSHOT - - 4.0.0 - flash-bench - - flash-bench - - - maven-shade-plugin - 3.5.3 - - - package - - shade - - - - - dev.relism.bench.Main - - - - - - *:* - - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - - - - - - - - - - - - org.projectlombok - lombok - 1.18.44 - provided - - - - 21 - UTF-8 - 2.12.3 - - diff --git a/flash-bench/pom.xml b/flash-bench/pom.xml deleted file mode 100644 index 92a1e88..0000000 --- a/flash-bench/pom.xml +++ /dev/null @@ -1,79 +0,0 @@ - - 4.0.0 - - - dev.relism - flash-parent - 1.0-SNAPSHOT - - - flash-bench - - - 21 - UTF-8 - 2.12.3 - - - - - dev.relism - flash - - - org.slf4j - slf4j-simple - - - org.projectlombok - lombok - - - org.hibernate.orm - hibernate-core - 6.6.4.Final - - - com.h2database - h2 - 2.3.232 - - - - - flash-bench - - - org.apache.maven.plugins - maven-shade-plugin - 3.5.3 - - - package - shade - - - - dev.relism.bench.Main - - - - - - *:* - - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - - - - - - - - - - \ No newline at end of file diff --git a/flash-bench/src/main/java/dev/relism/bench/Main.java b/flash-bench/src/main/java/dev/relism/bench/Main.java deleted file mode 100644 index 8483367..0000000 --- a/flash-bench/src/main/java/dev/relism/bench/Main.java +++ /dev/null @@ -1,148 +0,0 @@ -package dev.relism.bench; - -import dev.relism.HttpServer; -import dev.relism.HttpServerConfiguration; -import dev.relism.http.ContentType; -import org.asynchttpclient.AsyncHttpClient; -import org.asynchttpclient.DefaultAsyncHttpClientConfig; -import org.asynchttpclient.Dsl; -import org.asynchttpclient.Request; -import org.asynchttpclient.RequestBuilder; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.util.Arrays; - -public class Main { - - // Lazy holder — Netty starts only on the first AHC request, not at class load. - // Routes like /plaintext that never call AHC are unaffected. - private static final class AhcHolder { - static final AsyncHttpClient AHC = Dsl.asyncHttpClient( - new DefaultAsyncHttpClientConfig.Builder() - .setMaxConnections(256) - .setMaxConnectionsPerHost(256) - .setKeepAlive(true) - .setPooledConnectionIdleTimeout(60_000) - .setConnectTimeout(5_000) - .setRequestTimeout(10_000) - .setIoThreadsCount(1) - .build() - ); - } - - // Pre-built request objects — URI parsing done once at startup. - private static final Request ELEMENT_REQ = new RequestBuilder("GET") - .setUrl("http://web-data-source/element.json").build(); - private static final Request SHELLS_REQ = new RequestBuilder("GET") - .setUrl("http://web-data-source/shells.json").build(); - - private static final byte[] PREFIX_SHELLS = "{\"shells\":".getBytes(StandardCharsets.UTF_8); - private static final byte[] SUFFIX = "}".getBytes(StandardCharsets.UTF_8); - - public static void main(String[] args) throws Exception { - int port = args.length > 0 ? Integer.parseInt(args[0]) : 3000; - - HttpServer server = new HttpServer( - HttpServerConfiguration.builder() - .port(port).host("0.0.0.0").build() - ); - - server.get("/api/v1/periodic-table/element", (req, res) -> - fetchAndFilter(req.query("symbol"), false)); - server.get("/api/v1/periodic-table/shells", (req, res) -> - fetchAndFilter(req.query("symbol"), true)); - server.get("/plaintext", (req, res) -> "Hello, World!"); - server.get("/json", (req, res) -> { - res.setContentType(ContentType.JSON); - return "{\"message\":\"Hello, World!\"}"; - }); - - Path frontendDist = Path.of("C:\\Users\\elorc\\Documents\\Coding\\web\\PixelDocs\\.vitepress\\dist"); - StaticResourceManager cdn = new StaticResourceManager(frontendDist, "/"); - - cdn.register(server); - - server.start().thenRun(() -> System.out.println("Flash Sharkbench online on port " + port)); - } - - private static dev.relism.models.Response fetchAndFilter(String symbol, boolean wrapShells) { - if (symbol == null) return new dev.relism.models.Response(400, "Missing symbol", ContentType.TEXT_PLAIN); - try { - // executeRequest() returns a ListenableFuture; .get() parks the calling - // virtual thread (cheap) while Netty's I/O thread handles the socket. - byte[] body = AhcHolder.AHC.executeRequest(wrapShells ? SHELLS_REQ : ELEMENT_REQ) - .get() - .getResponseBodyAsBytes(); - - byte[] value = extractJsonValue(body, symbol); - if (value == null) return new dev.relism.models.Response(404, "Not Found", ContentType.TEXT_PLAIN); - - if (wrapShells) { - byte[] wrapped = new byte[PREFIX_SHELLS.length + value.length + SUFFIX.length]; - System.arraycopy(PREFIX_SHELLS, 0, wrapped, 0, PREFIX_SHELLS.length); - System.arraycopy(value, 0, wrapped, PREFIX_SHELLS.length, value.length); - System.arraycopy(SUFFIX, 0, wrapped, PREFIX_SHELLS.length + value.length, SUFFIX.length); - return new dev.relism.models.Response(200, wrapped, ContentType.JSON); - } - - return new dev.relism.models.Response(200, value, ContentType.JSON); - } catch (Exception e) { - return new dev.relism.models.Response(500, "Internal Error", ContentType.TEXT_PLAIN); - } - } - - /** - * Extracts the JSON value for a given key from a flat JSON object. - * Zero Jackson allocations — pure byte scan. - */ - private static byte[] extractJsonValue(byte[] json, String key) { - byte[] keyBytes = ("\"" + key + "\":").getBytes(StandardCharsets.UTF_8); - - int pos = indexOf(json, keyBytes); - if (pos == -1) return null; - pos += keyBytes.length; - - while (pos < json.length && json[pos] == ' ') pos++; - if (pos >= json.length) return null; - - byte opener = json[pos]; - byte closer; - if (opener == '{') closer = '}'; - else if (opener == '[') closer = ']'; - else return null; - - int depth = 0; - boolean inString = false; - int start = pos; - - while (pos < json.length) { - byte b = json[pos]; - if (b == '"' && !isEscaped(json, pos)) inString = !inString; - if (!inString) { - if (b == opener) depth++; - else if (b == closer) { if (--depth == 0) { pos++; break; } } - } - pos++; - } - - return Arrays.copyOfRange(json, start, pos); - } - - private static int indexOf(byte[] haystack, byte[] needle) { - outer: - for (int i = 0; i <= haystack.length - needle.length; i++) { - for (int j = 0; j < needle.length; j++) { - if (haystack[i + j] != needle[j]) continue outer; - } - return i; - } - return -1; - } - - private static boolean isEscaped(byte[] data, int pos) { - int backslashes = 0; - while (--pos >= 0 && data[pos] == '\\') backslashes++; - return (backslashes & 1) == 1; - } -} \ No newline at end of file diff --git a/flash-bench/src/main/java/dev/relism/bench/StaticResourceManager.java b/flash-bench/src/main/java/dev/relism/bench/StaticResourceManager.java deleted file mode 100644 index 64d8efb..0000000 --- a/flash-bench/src/main/java/dev/relism/bench/StaticResourceManager.java +++ /dev/null @@ -1,176 +0,0 @@ -package dev.relism.bench; - -import dev.relism.HttpServer; -import dev.relism.http.ContentType; -import dev.relism.http.HttpStatus; -import lombok.extern.slf4j.Slf4j; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.zip.GZIPOutputStream; - -@Slf4j -public class StaticResourceManager { - private static final int GZIP_THRESHOLD = 1024; - private static final Map MIME_TYPES = new HashMap<>(); - - static { - MIME_TYPES.put("html", ContentType.TEXT_HTML); - MIME_TYPES.put("css", ContentType.TEXT_CSS); - MIME_TYPES.put("js", ContentType.TEXT_JAVASCRIPT); - MIME_TYPES.put("mjs", ContentType.TEXT_JAVASCRIPT); - MIME_TYPES.put("png", ContentType.IMAGE_PNG); - MIME_TYPES.put("jpg", ContentType.IMAGE_JPEG); - MIME_TYPES.put("jpeg", ContentType.IMAGE_JPEG); - MIME_TYPES.put("svg", ContentType.IMAGE_SVG); - MIME_TYPES.put("json", ContentType.JSON); - MIME_TYPES.put("ico", ContentType.BINARY); - MIME_TYPES.put("webp", ContentType.BINARY); - } - - public record BakedAsset(byte[] raw, byte[] gzipped, ContentType contentType, boolean immutable, String etag) {} - - private final Map cache = new ConcurrentHashMap<>(); - private final String mountPath; - private BakedAsset indexFallback; - - // Campi per le statistiche del report - private long totalRawSize = 0, totalCompressedSize = 0; - private int compressedCount = 0; - - public StaticResourceManager(Path rootPath, String mountPath) { - this.mountPath = normalizeMountPath(mountPath); - - long start = System.currentTimeMillis(); - bakeAll(rootPath); - long duration = System.currentTimeMillis() - start; - - // Richiamo del report qui - printReport(duration); - } - - private String normalizeMountPath(String path) { - String p = path.startsWith("/") ? path : "/" + path; - return p.endsWith("/") ? p.substring(0, p.length() - 1) : p; - } - - private void bakeAll(Path rootPath) { - if (!Files.exists(rootPath)) return; - try (var stream = Files.walk(rootPath)) { - stream.filter(Files::isRegularFile).forEach(file -> { - try { - String relative = rootPath.relativize(file).toString().replace("\\", "/"); - byte[] raw = Files.readAllBytes(file); - byte[] gzipped = compressIfBeneficial(raw, relative); - String etag = "\"" + Integer.toHexString(Arrays.hashCode(raw)) + "-" + raw.length + "\""; - boolean isImmutable = relative.matches(".*\\.[a-f0-9]{8,}\\..*"); - - BakedAsset asset = new BakedAsset(raw, gzipped, resolveContentType(relative), isImmutable, etag); - cache.put(relative, asset); - - // Aggiornamento statistiche - totalRawSize += raw.length; - totalCompressedSize += (gzipped != null) ? gzipped.length : raw.length; - if (gzipped != null) compressedCount++; - if ("index.html".equals(relative)) indexFallback = asset; - } catch (IOException ignored) {} - }); - } catch (IOException ignored) {} - } - - // --- IL METODO PRINTREPORT --- - private void printReport(long duration) { - double saved = totalRawSize > 0 ? (1.0 - (double)totalCompressedSize / totalRawSize) * 100 : 0; - long immutableCount = cache.values().stream().filter(BakedAsset::immutable).count(); - - System.out.println("\n" + "=".repeat(45)); - System.out.printf("🚀 FLASH ASSET BAKE COMPLETE [%dms]\n", duration); - System.out.println("-".repeat(45)); - System.out.printf("📦 Total Assets: %d\n", cache.size()); - System.out.printf("🤐 Gzipped (On-Disk): %d\n", compressedCount); - System.out.printf("💾 RAM Usage: %.2f MB\n", (double)totalRawSize / (1024 * 1024)); - System.out.printf("📉 Bandwidth Saving: %.1f%%\n", saved); - System.out.println("-".repeat(45)); - System.out.println("🛡️ STRATEGIES ENABLED:"); - System.out.println(" • ETag Validation: [ACTIVE] (304 Not Modified)"); - System.out.printf(" • Immutable Assets: [%d files] (Cache: 1 year)\n", immutableCount); - System.out.println(" • SPA Fallback: [ENABLED] (Route -> index.html)"); - System.out.println("=".repeat(45) + "\n"); - } - - public void register(HttpServer server) { - server.get(mountPath + "/**", (req, res) -> { - String path = req.getRequestLine().getPath().toString(); - String subPath = path.substring(mountPath.length()).replaceFirst("^/", ""); - - BakedAsset asset = subPath.isEmpty() ? indexFallback : cache.get(subPath); - - if (asset == null) { - if (!subPath.contains(".") && indexFallback != null) { - return serve(res, indexFallback, req); - } - return res.status(HttpStatus.NOT_FOUND); - } - return serve(res, asset, req); - }); - } - - private Object serve(dev.relism.models.Response res, BakedAsset asset, dev.relism.models.Request req) { - // Caching condizionale (ETag) - String ifNoneMatch = req.header("If-None-Match"); - if (asset.etag().equals(ifNoneMatch)) { - return res.status(HttpStatus.NOT_MODIFIED); - } - - res.type(asset.contentType()); - res.header("ETag", asset.etag()); - - // Cache-Control Strategy - if (asset.contentType() == ContentType.TEXT_HTML) { - res.header("Cache-Control", "no-cache, must-revalidate"); - } else if (asset.immutable()) { - res.header("Cache-Control", "public, max-age=31536000, immutable"); - } else { - res.header("Cache-Control", "public, max-age=3600"); - } - - if (asset.gzipped() != null && acceptsGzip(req)) { - res.header("Content-Encoding", "gzip"); - res.header("Vary", "Accept-Encoding"); - return res.body(asset.gzipped()); - } - return res.body(asset.raw()); - } - - private boolean acceptsGzip(dev.relism.models.Request req) { - String enc = req.header("Accept-Encoding"); - return enc != null && enc.contains("gzip"); - } - - private byte[] compressIfBeneficial(byte[] data, String name) { - if (data.length < GZIP_THRESHOLD || isAlreadyCompressed(name)) return null; - try (var baos = new ByteArrayOutputStream(); var gzip = new GZIPOutputStream(baos)) { - gzip.write(data); - gzip.finish(); - byte[] compressed = baos.toByteArray(); - return compressed.length < (data.length * 0.9) ? compressed : null; - } catch (IOException e) { return null; } - } - - private boolean isAlreadyCompressed(String n) { - n = n.toLowerCase(); - return n.endsWith(".png") || n.endsWith(".jpg") || n.endsWith(".jpeg") || n.endsWith(".ico") || n.endsWith(".webp"); - } - - private ContentType resolveContentType(String filename) { - int dot = filename.lastIndexOf('.'); - String ext = (dot > 0) ? filename.substring(dot + 1).toLowerCase() : ""; - return MIME_TYPES.getOrDefault(ext, ContentType.BINARY); - } -} \ No newline at end of file diff --git a/flash-bench/src/main/resources/element.json b/flash-bench/src/main/resources/element.json deleted file mode 100644 index 0353f15..0000000 --- a/flash-bench/src/main/resources/element.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "H": {"name": "Hydrogen", "number": 1, "group": 1}, - "He": {"name": "Helium", "number": 2, "group": 18}, - "Li": {"name": "Lithium", "number": 3, "group": 1}, - "Be": {"name": "Beryllium", "number": 4, "group": 2}, - "B": {"name": "Boron", "number": 5, "group": 13}, - "C": {"name": "Carbon", "number": 6, "group": 14}, - "N": {"name": "Nitrogen", "number": 7, "group": 15}, - "O": {"name": "Oxygen", "number": 8, "group": 16}, - "F": {"name": "Fluorine", "number": 9, "group": 17}, - "Ne": {"name": "Neon", "number": 10, "group": 18} -} diff --git a/flash-bench/src/main/resources/shells.json b/flash-bench/src/main/resources/shells.json deleted file mode 100644 index e645b57..0000000 --- a/flash-bench/src/main/resources/shells.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "H": {"shells": [1]}, - "He": {"shells": [2]}, - "Li": {"shells": [2, 1]}, - "Be": {"shells": [2, 2]}, - "B": {"shells": [2, 3]}, - "C": {"shells": [2, 4]}, - "N": {"shells": [2, 5]}, - "O": {"shells": [2, 6]}, - "F": {"shells": [2, 7]}, - "Ne": {"shells": [2, 8]} -} diff --git a/flash-bench/src/main/resources/simplelogger.properties b/flash-bench/src/main/resources/simplelogger.properties deleted file mode 100644 index 263f2a3..0000000 --- a/flash-bench/src/main/resources/simplelogger.properties +++ /dev/null @@ -1,2 +0,0 @@ -# Silence all logs during benchmarks to avoid I/O becoming the bottleneck -org.slf4j.simpleLogger.defaultLogLevel=off diff --git a/jmh-result.text b/jmh-result.text deleted file mode 100644 index 48aabd1..0000000 --- a/jmh-result.text +++ /dev/null @@ -1 +0,0 @@ -Benchmark Mode Cnt Score Error Units diff --git a/pom.xml b/pom.xml index be9c68e..61f2318 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,6 @@ flash - flash-bench