diff --git a/.gitignore b/.gitignore index e12ca52..e3d1024 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,13 @@ build/ ### Mac OS ### .DS_Store -CLAUDE.md \ No newline at end of file +CLAUDE.md + +### Local / scratch ### +flash/src/main/java/dev/relism/Main.java +flash-bench/ +nuxt-shadcn-dashboard/ +/dev/ +/docs/ +jmh-result.text +*.text \ No newline at end of file diff --git a/flash/src/main/java/dev/relism/Main.java b/flash/src/main/java/dev/relism/Main.java deleted file mode 100644 index 3be7184..0000000 --- a/flash/src/main/java/dev/relism/Main.java +++ /dev/null @@ -1,141 +0,0 @@ -package dev.relism; - -import dev.relism.api.multipart.Multipart; -import dev.relism.api.multipart.Part; -import dev.relism.http.ContentType; -import dev.relism.http.HttpStatus; -import lombok.extern.slf4j.Slf4j; - -import java.io.*; -import java.nio.file.*; -import java.util.List; - -@Slf4j -public class Main { - - private static final int PORT = 8080; - private static final Path UPLOAD_DIR = Path.of(System.getProperty("java.io.tmpdir"), "flash-uploads"); - - public static void main(String[] args) throws IOException { - Files.createDirectories(UPLOAD_DIR); - - HttpServer server = new HttpServer( - HttpServerConfiguration.builder().port(PORT).build()); - - server.get("/plaintext", (req, res) -> { - res.type(ContentType.TEXT_PLAIN); - return "Hello, World!"; - }); - - // ── File serving ────────────────────────────────────────────────────── - - server.get("/files/{name}", (req, res) -> { - Path file = UPLOAD_DIR.resolve(req.param("name")); - if (!Files.exists(file)) - return res.status(404).body("not found"); - - return res.type(ContentType.BINARY) - .stream(Files.newInputStream(file), Files.size(file)) - .header("Content-Disposition", "attachment; filename=\"" + file.getFileName() + "\""); - }); - - // ── Chunked / dynamic content ───────────────────────────────────────── - - // Virtual thread produces rows into a pipe; socket thread drains in 8 KB chunks. - // No Content-Length needed — Transfer-Encoding: chunked. - server.get("/export/users.csv", (req, res) -> { - PipedOutputStream sink = new PipedOutputStream(); - PipedInputStream source = new PipedInputStream(sink, 64 * 1024); - - Thread.ofVirtual().start(() -> { - try (PrintWriter w = new PrintWriter(new BufferedWriter(new OutputStreamWriter(sink)))) { - w.println("id,name,email"); - for (int i = 1; i <= 100_000; i++) - w.printf("%d,User %d,user%d@example.com%n", i, i, i); - } - }); - - return res.type(ContentType.TEXT_CSV) - .chunked(source) - .header("Content-Disposition", "attachment; filename=\"users.csv\""); - }); - - // ── Multipart upload ────────────────────────────────────────────────── - - // Text fields are buffered eagerly; file is streamed zero-copy to disk. - server.post("/upload/avatar", (req, res) -> { - Multipart mp = Multipart.of(req); - String userId = mp.field("userId"); - Part avatar = mp.file("avatar"); - - if (userId == null || avatar == null) - return res.status(400).body("userId and avatar fields are required"); - - Path dest = UPLOAD_DIR.resolve(userId + "_" + avatar.filename()); - Files.copy(avatar.stream(), dest, StandardCopyOption.REPLACE_EXISTING); - - return res.body("saved %s for user %s".formatted(avatar.filename(), userId)); - }); - - // parts() forces a full scan and materialises all file bodies — accepted cost. - server.post("/upload/bulk", (req, res) -> { - List files = Multipart.of(req).parts("files"); - if (files.isEmpty()) - return res.status(HttpStatus.BAD_REQUEST).body("no files uploaded"); - - StringBuilder sb = new StringBuilder(); - for (Part f : files) { - byte[] data = f.materialize(); - Files.write(UPLOAD_DIR.resolve(f.filename()), data); - sb.append("%s %d bytes %s%n".formatted(f.filename(), data.length, f.contentType())); - } - return res.body(sb.toString()); - }); - - // Echo: file bytes flow socket-in → pipe → chunked-out. - // The pipe decouples reading (request socket) from writing (response socket) so they - // proceed concurrently on separate virtual threads, preventing the TCP deadlock that - // would occur if both happened sequentially on the same thread for large files. - server.post("/upload/echo", (req, res) -> { - Part file = Multipart.of(req).file("file"); - if (file == null) - return res.status(400).body("missing file field"); - - PipedOutputStream sink = new PipedOutputStream(); - PipedInputStream source = new PipedInputStream(sink, 64 * 1024); - - InputStream fileStream = file.stream(); - Thread.ofVirtual().start(() -> { - try { fileStream.transferTo(sink); } catch (IOException ignored) {} - finally { try { sink.close(); } catch (IOException ignored) {} } - }); - - String ct = file.contentType(); - return res.type(ct != null ? ct : "application/octet-stream") - .chunked(source) - .header("Content-Disposition", "attachment; filename=\"" + file.filename() + "\""); - }); - - // Mixed form: text fields parsed eagerly, image streamed back with Content-Length. - server.post("/upload/resize-preview", (req, res) -> { - Multipart mp = Multipart.of(req); - String width = mp.field("width"); - String height = mp.field("height"); - Part image = mp.file("image"); - - if (image == null) - return res.status(400).body("image field required"); - - // materialize() accepted here — preview endpoint, size bounded - byte[] data = image.materialize(); - log.info("resize request {}x{} for {} ({} bytes)", width, height, image.filename(), data.length); - - return res.type(ContentType.BINARY) - .stream(new ByteArrayInputStream(data), data.length) - .header("X-Original-Name", image.filename()) - .header("X-Requested-Size", width + "x" + height); - }); - - server.start().thenRun(() -> log.info("Server started on :{}", PORT)); - } -} diff --git a/flash/src/main/java/dev/relism/routing/Middleware.java b/flash/src/main/java/dev/relism/routing/Middleware.java new file mode 100644 index 0000000..f925880 --- /dev/null +++ b/flash/src/main/java/dev/relism/routing/Middleware.java @@ -0,0 +1,82 @@ +package dev.relism.routing; + +import dev.relism.models.RequestHandler; +import dev.relism.models.SimpleHandler; + +/** + * Stateless interceptor applied around a {@link RequestHandler}. + * + *

Middleware is composed once at registration time and stored as a pre-built handler + * chain inside the router's state machine. There is zero runtime overhead: no lists are visited, + * no lookups performed, and no objects allocated on the request hot-path. + * + *

Middleware is intentionally stateless — it can read {@link dev.relism.models.Request}, + * write to {@link dev.relism.models.Response}, and decide whether to call {@code next} or + * short-circuit. Shared state between layers belongs in HDI handler chains, not here. + * + *

{@code
+ * // Inline definition — pure lambda, no wrapper types needed
+ * Middleware auth = next -> (req, res) -> {
+ *     if (req.header("Authorization") == null) { res.status(401); return null; }
+ *     return next.handle(req, res);
+ * };
+ *
+ * // Named reusable chain — composed into a single object at call time
+ * Middleware secured = Middleware.of(cors, auth, rateLimit);
+ *
+ * // Router-level: applied to every handler registered on this router
+ * AbstractRouter api = new MyRouter(cors, auth);
+ *
+ * // Handler-level: applied only to this route
+ * router.get("/admin", (req, res) -> "secret", auth);
+ * router.register(new AdminHandler(), auth, logging);
+ * }
+ */ +@FunctionalInterface +public interface Middleware { + + /** + * Wraps {@code next} with this middleware's logic. Called once at boot time + * during handler registration. Returns a {@link SimpleHandler.FunctionalHandler} lambda + * — no wrapper types required at the call site. + * + *

The returned functional handler is boxed into a {@link SimpleHandler} internally by + * {@link AbstractRouter}; callers never need to do this manually. + */ + SimpleHandler.FunctionalHandler wrap(RequestHandler next); + + /** + * Composes an ordered chain of middlewares into a single {@link Middleware}. + * The first element is the outermost wrapper and therefore executes first. + * + *

The composition loop runs once when {@link #wrap} is called (i.e. at registration + * time), not at construction of the chain. Zero allocations on the hot-path. + * + *

{@code
+     * Middleware secured = Middleware.of(cors, auth, rateLimit);
+     * // execution order: cors → auth → rateLimit → handler
+     * }
+ */ + static Middleware of(Middleware... chain) { + if (chain.length == 0) return next -> next::handle; + if (chain.length == 1) return chain[0]; + return next -> { + RequestHandler h = next; + for (int i = chain.length - 1; i >= 0; i--) + h = new SimpleHandler(chain[i].wrap(h)); + return h::handle; + }; + } + + /** + * Returns a composed middleware that applies {@code this} first, then {@code after}. + * Prefer {@link #of} for three or more middlewares. + * + *
{@code
+     * Middleware secured = cors.andThen(auth);
+     * }
+ */ + default Middleware andThen(Middleware after) { + return next -> this.wrap(new SimpleHandler(after.wrap(next))); + } +}