From e2c6239a6f9b702bc466e3d872773f3e3a528e74 Mon Sep 17 00:00:00 2001 From: BennyFranciscus <268274351+BennyFranciscus@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:40:48 +0000 Subject: [PATCH 1/9] Add handy-httpd: D language HTTP server (first D entry!) Adds handy-httpd, an extremely lightweight HTTP server for the D programming language by @andrewlalis. Solo-dev passion project with ~36 stars, maintained since 2021. D compiles to native code via LLVM (LDC2) and offers manual memory management with optional GC - an interesting performance data point between C/C++ and higher-level languages. Implements all benchmark endpoints: - /pipeline, /baseline11, /baseline2 - /json, /compression, /db - /upload, /static/{filename} --- frameworks/handy-httpd/Dockerfile | 29 +++ frameworks/handy-httpd/README.md | 20 ++ frameworks/handy-httpd/dub.sdl | 9 + frameworks/handy-httpd/meta.json | 20 ++ frameworks/handy-httpd/src/app.d | 326 ++++++++++++++++++++++++++++++ 5 files changed, 404 insertions(+) create mode 100644 frameworks/handy-httpd/Dockerfile create mode 100644 frameworks/handy-httpd/README.md create mode 100644 frameworks/handy-httpd/dub.sdl create mode 100644 frameworks/handy-httpd/meta.json create mode 100644 frameworks/handy-httpd/src/app.d diff --git a/frameworks/handy-httpd/Dockerfile b/frameworks/handy-httpd/Dockerfile new file mode 100644 index 00000000..40380e9a --- /dev/null +++ b/frameworks/handy-httpd/Dockerfile @@ -0,0 +1,29 @@ +FROM ubuntu:24.04 AS build + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + curl gcc xz-utils ca-certificates \ + libsqlite3-dev zlib1g-dev && \ + rm -rf /var/lib/apt/lists/* + +# Install LDC +ARG LDC_VERSION=1.42.0 +RUN curl -fsSL "https://github.com/ldc-developers/ldc/releases/download/v${LDC_VERSION}/ldc2-${LDC_VERSION}-linux-x86_64.tar.xz" \ + | tar xJ -C /opt && \ + ln -s /opt/ldc2-${LDC_VERSION}-linux-x86_64 /opt/ldc +ENV PATH="/opt/ldc/bin:${PATH}" + +WORKDIR /app +COPY dub.sdl . +COPY src ./src +RUN dub build -b release --compiler=ldc2 + +FROM ubuntu:24.04 +RUN apt-get update && \ + apt-get install -y --no-install-recommends libsqlite3-0 zlib1g && \ + rm -rf /var/lib/apt/lists/* +COPY --from=build /app/httparena-handy-httpd /server +COPY --from=build /opt/ldc/lib/libphobos2-ldc-shared.so.2 /usr/lib/ +COPY --from=build /opt/ldc/lib/libdruntime-ldc-shared.so.2 /usr/lib/ +EXPOSE 8080 +CMD ["/server"] diff --git a/frameworks/handy-httpd/README.md b/frameworks/handy-httpd/README.md new file mode 100644 index 00000000..a6cd065b --- /dev/null +++ b/frameworks/handy-httpd/README.md @@ -0,0 +1,20 @@ +# handy-httpd + +An extremely lightweight HTTP server for the [D programming language](https://dlang.org/). + +- **Language:** D +- **Repository:** https://github.com/andrewlalis/handy-httpd +- **Stars:** ~36 +- **Compiler:** LDC2 (LLVM-based D compiler) + +## About + +handy-httpd is a solo-dev passion project by [@andrewlalis](https://github.com/andrewlalis), maintained since 2021. It provides a clean, composable API with routing via `PathHandler`, WebSocket support, and middleware via filters — all while staying extremely lightweight. + +D compiles to native code via LLVM (LDC) and offers manual memory management with optional GC, making it an interesting performance data point between C/C++ and higher-level languages. + +## Build + +```bash +docker build -t httparena-handy-httpd . +``` diff --git a/frameworks/handy-httpd/dub.sdl b/frameworks/handy-httpd/dub.sdl new file mode 100644 index 00000000..63c03db8 --- /dev/null +++ b/frameworks/handy-httpd/dub.sdl @@ -0,0 +1,9 @@ +name "httparena-handy-httpd" +targetType "executable" +targetName "httparena-handy-httpd" + +dependency "handy-httpd" version="~>8.7.0" +dependency "d2sqlite3" version="~>1.0.0" + +dflags "-O3" "-release" "-boundscheck=off" platform="ldc" +lflags "-lsqlite3" "-lz" diff --git a/frameworks/handy-httpd/meta.json b/frameworks/handy-httpd/meta.json new file mode 100644 index 00000000..24825b7d --- /dev/null +++ b/frameworks/handy-httpd/meta.json @@ -0,0 +1,20 @@ +{ + "display_name": "handy-httpd", + "language": "D", + "type": "framework", + "engine": "handy-httpd", + "description": "Extremely lightweight HTTP server for the D programming language. Solo-dev passion project with routing, WebSocket support, and clean API.", + "repo": "https://github.com/andrewlalis/handy-httpd", + "enabled": true, + "tests": [ + "baseline", + "noisy", + "pipelined", + "limited-conn", + "json", + "upload", + "compression", + "mixed", + "static-h2" + ] +} diff --git a/frameworks/handy-httpd/src/app.d b/frameworks/handy-httpd/src/app.d new file mode 100644 index 00000000..73f8e828 --- /dev/null +++ b/frameworks/handy-httpd/src/app.d @@ -0,0 +1,326 @@ +module app; + +import handy_httpd; +import handy_httpd.handlers.path_handler; +import d2sqlite3; +import std.json; +import std.conv : to; +import std.format : format; +import std.math : round; +import std.file : read, readText, dirEntries, SpanMode; +import std.path : extension; +import std.string : strip; +import std.algorithm : splitter; +import std.zlib : Compress, HeaderFormat; + +private enum SERVER_NAME = "handy-httpd"; + +// --- Data types --- + +struct Rating { + double score; + long count; +} + +struct DatasetItem { + long id; + string name; + string category; + double price; + long quantity; + bool active; + string[] tags; + Rating rating; +} + +// --- Global state --- + +private __gshared DatasetItem[] dataset; +private __gshared ubyte[] jsonLargeCache; +private __gshared ubyte[][string] staticFiles; +private __gshared string[string] staticContentTypes; + +// --- Helpers --- + +DatasetItem[] loadDataset(string path) { + DatasetItem[] items; + try { + string data = readText(path); + JSONValue arr = parseJSON(data); + foreach (ref item; arr.array) { + DatasetItem d; + d.id = item["id"].get!long; + d.name = item["name"].str; + d.category = item["category"].str; + d.price = item["price"].type == JSONType.integer + ? cast(double) item["price"].get!long + : item["price"].get!double; + d.quantity = item["quantity"].get!long; + d.active = item["active"].type == JSONType.true_; + foreach (ref t; item["tags"].array) + d.tags ~= t.str; + d.rating.score = item["rating"]["score"].type == JSONType.integer + ? cast(double) item["rating"]["score"].get!long + : item["rating"]["score"].get!double; + d.rating.count = item["rating"]["count"].get!long; + items ~= d; + } + } catch (Exception e) {} + return items; +} + +JSONValue buildJsonResponse(const DatasetItem[] items) { + JSONValue[] jsonItems; + foreach (ref d; items) { + JSONValue item = JSONValue(string[string].init); + item["id"] = JSONValue(d.id); + item["name"] = JSONValue(d.name); + item["category"] = JSONValue(d.category); + item["price"] = JSONValue(d.price); + item["quantity"] = JSONValue(d.quantity); + item["active"] = JSONValue(d.active); + JSONValue[] tagsArr; + foreach (ref t; d.tags) + tagsArr ~= JSONValue(t); + item["tags"] = JSONValue(tagsArr); + JSONValue rat = JSONValue(string[string].init); + rat["score"] = JSONValue(d.rating.score); + rat["count"] = JSONValue(d.rating.count); + item["rating"] = rat; + item["total"] = JSONValue(round(d.price * cast(double) d.quantity * 100.0) / 100.0); + jsonItems ~= item; + } + JSONValue resp = JSONValue(string[string].init); + resp["items"] = JSONValue(jsonItems); + resp["count"] = JSONValue(cast(long) jsonItems.length); + return resp; +} + +long parseQuerySum(string query) { + long sum = 0; + foreach (pair; query.splitter('&')) { + import std.algorithm : findSplitAfter; + auto parts = pair.findSplitAfter("="); + if (parts[1].length > 0) { + try { + sum += parts[1].to!long; + } catch (Exception e) {} + } + } + return sum; +} + +void loadStaticFiles() { + immutable string[string] mimeTypes = [ + ".css": "text/css", + ".js": "application/javascript", + ".html": "text/html", + ".woff2": "font/woff2", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".json": "application/json", + ]; + try { + foreach (entry; dirEntries("/data/static", SpanMode.shallow)) { + import std.path : baseName; + string name = baseName(entry.name); + string ext = extension(name); + string ct = ext in mimeTypes ? mimeTypes[ext] : "application/octet-stream"; + staticFiles[name] = cast(ubyte[]) read(entry.name); + staticContentTypes[name] = ct; + } + } catch (Exception e) {} +} + +// --- Route handlers --- + +void pipelineHandler(ref HttpRequestContext ctx) { + ctx.response.addHeader("Server", SERVER_NAME); + ctx.response.writeBodyString("ok", "text/plain"); +} + +void baseline11Handler(ref HttpRequestContext ctx) { + string query = ctx.request.url.length > 0 ? "" : ""; + // Extract query string from the raw URL + long sum = 0; + + // Parse query params from the request + foreach (key, value; ctx.request.queryParams) { + try { + sum += value.to!long; + } catch (Exception e) {} + } + + // If POST, also read body + if (ctx.request.method == Method.POST) { + try { + string body = ctx.request.readBodyAsString().strip(); + if (body.length > 0) { + sum += body.to!long; + } + } catch (Exception e) {} + } + + ctx.response.addHeader("Server", SERVER_NAME); + ctx.response.writeBodyString(sum.to!string, "text/plain"); +} + +void baseline2Handler(ref HttpRequestContext ctx) { + long sum = 0; + foreach (key, value; ctx.request.queryParams) { + try { + sum += value.to!long; + } catch (Exception e) {} + } + ctx.response.addHeader("Server", SERVER_NAME); + ctx.response.writeBodyString(sum.to!string, "text/plain"); +} + +void jsonHandler(ref HttpRequestContext ctx) { + if (dataset.length == 0) { + ctx.response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR); + ctx.response.writeBodyString("No dataset", "text/plain"); + return; + } + JSONValue resp = buildJsonResponse(dataset); + ctx.response.addHeader("Server", SERVER_NAME); + ctx.response.writeBodyString(resp.toString(), "application/json"); +} + +void compressionHandler(ref HttpRequestContext ctx) { + // Check if client accepts gzip + string acceptEncoding = ctx.request.headers.getFirst("Accept-Encoding").orElse(""); + import std.algorithm : canFind; + if (acceptEncoding.canFind("gzip") && jsonLargeCache.length > 0) { + auto compress = new Compress(6, HeaderFormat.gzip); + auto compressed = compress.compress(jsonLargeCache); + compressed ~= compress.flush(); + ctx.response.addHeader("Server", SERVER_NAME); + ctx.response.addHeader("Content-Encoding", "gzip"); + ctx.response.writeBodyBytes(cast(ubyte[]) compressed, "application/json"); + } else { + ctx.response.addHeader("Server", SERVER_NAME); + ctx.response.writeBodyBytes(jsonLargeCache, "application/json"); + } +} + +void uploadHandler(ref HttpRequestContext ctx) { + ubyte[] body = ctx.request.readBodyAsBytes(); + ctx.response.addHeader("Server", SERVER_NAME); + ctx.response.writeBodyString(body.length.to!string, "text/plain"); +} + +void dbHandler(ref HttpRequestContext ctx) { + double minPrice = 10.0; + double maxPrice = 50.0; + + auto minStr = ctx.request.queryParams.getFirst("min"); + if (!minStr.isNull) { + try { minPrice = minStr.get.to!double; } catch (Exception e) {} + } + auto maxStr = ctx.request.queryParams.getFirst("max"); + if (!maxStr.isNull) { + try { maxPrice = maxStr.get.to!double; } catch (Exception e) {} + } + + try { + auto db = Database("/data/benchmark.db", SQLITE_OPEN_READONLY); + auto stmt = db.prepare( + "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN ?1 AND ?2 LIMIT 50" + ); + stmt.bind(1, minPrice); + stmt.bind(2, maxPrice); + + JSONValue[] items; + foreach (row; stmt.execute()) { + JSONValue item = JSONValue(string[string].init); + item["id"] = JSONValue(row.peek!long(0)); + item["name"] = JSONValue(row.peek!string(1)); + item["category"] = JSONValue(row.peek!string(2)); + item["price"] = JSONValue(row.peek!double(3)); + item["quantity"] = JSONValue(row.peek!long(4)); + item["active"] = JSONValue(row.peek!long(5) == 1); + try { + item["tags"] = parseJSON(row.peek!string(6)); + } catch (Exception e) { + item["tags"] = JSONValue((JSONValue[]).init); + } + JSONValue rat = JSONValue(string[string].init); + rat["score"] = JSONValue(row.peek!double(7)); + rat["count"] = JSONValue(row.peek!long(8)); + item["rating"] = rat; + items ~= item; + } + + JSONValue result = JSONValue(string[string].init); + result["items"] = JSONValue(items); + result["count"] = JSONValue(cast(long) items.length); + + ctx.response.addHeader("Server", SERVER_NAME); + ctx.response.writeBodyString(result.toString(), "application/json"); + } catch (Exception e) { + ctx.response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR); + ctx.response.writeBodyString("Database error", "text/plain"); + } +} + +void staticHandler(ref HttpRequestContext ctx) { + string filename = ctx.request.getPathParamAs!string("filename"); + if (filename in staticFiles) { + ctx.response.addHeader("Server", SERVER_NAME); + ctx.response.writeBodyBytes(staticFiles[filename], staticContentTypes[filename]); + } else { + ctx.response.setStatus(HttpStatus.NOT_FOUND); + ctx.response.writeBodyString("Not found", "text/plain"); + } +} + +// --- Main --- + +void main() { + import std.process : environment; + + // Load data + string datasetPath = environment.get("DATASET_PATH", "/data/dataset.json"); + dataset = loadDataset(datasetPath); + + // Load large dataset for compression endpoint + auto largeDataset = loadDataset("/data/dataset-large.json"); + if (largeDataset.length > 0) { + JSONValue largeResp = buildJsonResponse(largeDataset); + string largeJson = largeResp.toString(); + jsonLargeCache = cast(ubyte[]) largeJson.dup; + } + + // Load static files + loadStaticFiles(); + + // Set up routes + auto router = new PathHandler(); + router.addMapping(Method.GET, "/pipeline", &pipelineHandler); + router.addMapping(Method.GET, "/baseline11", &baseline11Handler); + router.addMapping(Method.POST, "/baseline11", &baseline11Handler); + router.addMapping(Method.GET, "/baseline2", &baseline2Handler); + router.addMapping(Method.GET, "/json", &jsonHandler); + router.addMapping(Method.GET, "/compression", &compressionHandler); + router.addMapping(Method.POST, "/upload", &uploadHandler); + router.addMapping(Method.GET, "/db", &dbHandler); + router.addMapping(Method.GET, "/static/:filename", &staticHandler); + + // Configure server + ServerConfig cfg; + cfg.port = 8080; + cfg.hostname = "0.0.0.0"; + cfg.connectionQueueSize = 4096; + cfg.receiveBufferSize = 16384; + + import core.cpuid : threadsPerCPU; + auto cpus = threadsPerCPU(); + if (cpus > 0) + cfg.workerPoolSize = cpus; + else + cfg.workerPoolSize = 4; + + auto server = new HttpServer(router, cfg); + server.start(); +} From e8688148f7410fde53d679995cc79df269015349 Mon Sep 17 00:00:00 2001 From: BennyFranciscus <268274351+BennyFranciscus@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:06:21 +0000 Subject: [PATCH 2/9] =?UTF-8?q?fix(handy-httpd):=20use=20.value=20instead?= =?UTF-8?q?=20of=20.get=20on=20Optional=20=E2=80=94=20LDC=201.42=20conflic?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LDC 1.42's object.get template conflicts with handy-httpd's Optional.get. The Optional struct has a public .value field, so access that directly instead. Also added pkg-config to build deps to silence dub warnings. --- frameworks/handy-httpd/Dockerfile | 2 +- frameworks/handy-httpd/src/app.d | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frameworks/handy-httpd/Dockerfile b/frameworks/handy-httpd/Dockerfile index 40380e9a..c8869590 100644 --- a/frameworks/handy-httpd/Dockerfile +++ b/frameworks/handy-httpd/Dockerfile @@ -2,7 +2,7 @@ FROM ubuntu:24.04 AS build RUN apt-get update && \ apt-get install -y --no-install-recommends \ - curl gcc xz-utils ca-certificates \ + curl gcc xz-utils ca-certificates pkg-config \ libsqlite3-dev zlib1g-dev && \ rm -rf /var/lib/apt/lists/* diff --git a/frameworks/handy-httpd/src/app.d b/frameworks/handy-httpd/src/app.d index 73f8e828..3353ba32 100644 --- a/frameworks/handy-httpd/src/app.d +++ b/frameworks/handy-httpd/src/app.d @@ -216,11 +216,11 @@ void dbHandler(ref HttpRequestContext ctx) { auto minStr = ctx.request.queryParams.getFirst("min"); if (!minStr.isNull) { - try { minPrice = minStr.get.to!double; } catch (Exception e) {} + try { minPrice = minStr.value.to!double; } catch (Exception e) {} } auto maxStr = ctx.request.queryParams.getFirst("max"); if (!maxStr.isNull) { - try { maxPrice = maxStr.get.to!double; } catch (Exception e) {} + try { maxPrice = maxStr.value.to!double; } catch (Exception e) {} } try { From 6eba27f6e097d23a46eb60d95a4cdb4caafb41f7 Mon Sep 17 00:00:00 2001 From: BennyFranciscus <268274351+BennyFranciscus@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:32:44 +0000 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20static=20link=20D=20runtime=20?= =?UTF-8?q?=E2=80=94=20LDC=201.42=20shared=20lib=20paths=20changed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LDC 1.42 moved shared libraries from /opt/ldc/lib/ to a different path. Instead of chasing paths, switch to static linking with -link-defaultlib-shared=false. Produces a self-contained binary that doesn't need runtime .so files copied. --- frameworks/handy-httpd/Dockerfile | 2 -- frameworks/handy-httpd/dub.sdl | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/frameworks/handy-httpd/Dockerfile b/frameworks/handy-httpd/Dockerfile index c8869590..7be55194 100644 --- a/frameworks/handy-httpd/Dockerfile +++ b/frameworks/handy-httpd/Dockerfile @@ -23,7 +23,5 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends libsqlite3-0 zlib1g && \ rm -rf /var/lib/apt/lists/* COPY --from=build /app/httparena-handy-httpd /server -COPY --from=build /opt/ldc/lib/libphobos2-ldc-shared.so.2 /usr/lib/ -COPY --from=build /opt/ldc/lib/libdruntime-ldc-shared.so.2 /usr/lib/ EXPOSE 8080 CMD ["/server"] diff --git a/frameworks/handy-httpd/dub.sdl b/frameworks/handy-httpd/dub.sdl index 63c03db8..30512b4c 100644 --- a/frameworks/handy-httpd/dub.sdl +++ b/frameworks/handy-httpd/dub.sdl @@ -5,5 +5,5 @@ targetName "httparena-handy-httpd" dependency "handy-httpd" version="~>8.7.0" dependency "d2sqlite3" version="~>1.0.0" -dflags "-O3" "-release" "-boundscheck=off" platform="ldc" +dflags "-O3" "-release" "-boundscheck=off" "-link-defaultlib-shared=false" platform="ldc" lflags "-lsqlite3" "-lz" From 34d32e9a3c2cf1a711fcbcfa389c56da4f6e6556 Mon Sep 17 00:00:00 2001 From: BennyFranciscus <268274351+BennyFranciscus@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:51:55 +0000 Subject: [PATCH 4/9] fix: remove noisy and static-h2 tests from meta.json - noisy: handy-httpd v8 defaults unknown HTTP methods to GET (library behavior), so GETT requests hit GET handlers and return 200 instead of 4xx. Can't fix without patching the library. - static-h2: handy-httpd doesn't support HTTP/2 or TLS. --- frameworks/handy-httpd/meta.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frameworks/handy-httpd/meta.json b/frameworks/handy-httpd/meta.json index 24825b7d..b2b8c81b 100644 --- a/frameworks/handy-httpd/meta.json +++ b/frameworks/handy-httpd/meta.json @@ -8,13 +8,11 @@ "enabled": true, "tests": [ "baseline", - "noisy", "pipelined", "limited-conn", "json", "upload", "compression", - "mixed", - "static-h2" + "mixed" ] } From 3b33cd75c2e4a50c36691613dfa985c1e67cf842 Mon Sep 17 00:00:00 2001 From: BennyFranciscus <268274351+BennyFranciscus@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:59:26 +0000 Subject: [PATCH 5/9] Fix chunked Transfer-Encoding body handling handy-httpd v8 passes through raw chunked framing in readBodyAsString(). Add manual chunked TE decoder for baseline11 and upload endpoints so the body payload is correctly extracted from chunk frames. --- frameworks/handy-httpd/src/app.d | 89 ++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/frameworks/handy-httpd/src/app.d b/frameworks/handy-httpd/src/app.d index 3353ba32..9588f29f 100644 --- a/frameworks/handy-httpd/src/app.d +++ b/frameworks/handy-httpd/src/app.d @@ -132,6 +132,70 @@ void loadStaticFiles() { } catch (Exception e) {} } +// --- Chunked TE decoder --- + +string decodeChunkedPayload(string raw) { + string result; + size_t pos = 0; + while (pos < raw.length) { + // Find the end of the chunk size line + size_t lineEnd = pos; + while (lineEnd < raw.length && raw[lineEnd] != '\r' && raw[lineEnd] != '\n') + lineEnd++; + if (lineEnd == pos) break; + string sizeStr = raw[pos .. lineEnd].strip(); + long chunkSize; + try { + chunkSize = sizeStr.to!long(16); + } catch (Exception e) { + break; + } + if (chunkSize == 0) break; + // Skip past \r\n + pos = lineEnd; + if (pos < raw.length && raw[pos] == '\r') pos++; + if (pos < raw.length && raw[pos] == '\n') pos++; + // Read chunk data + size_t end = pos + cast(size_t) chunkSize; + if (end > raw.length) end = raw.length; + result ~= raw[pos .. end]; + pos = end; + // Skip trailing \r\n + if (pos < raw.length && raw[pos] == '\r') pos++; + if (pos < raw.length && raw[pos] == '\n') pos++; + } + return result; +} + +ubyte[] decodeChunkedBytes(ubyte[] raw) { + ubyte[] result; + size_t pos = 0; + while (pos < raw.length) { + size_t lineEnd = pos; + while (lineEnd < raw.length && raw[lineEnd] != '\r' && raw[lineEnd] != '\n') + lineEnd++; + if (lineEnd == pos) break; + string sizeStr = (cast(string) raw[pos .. lineEnd]).strip(); + long chunkSize; + try { + chunkSize = sizeStr.to!long(16); + } catch (Exception e) { + break; + } + if (chunkSize == 0) break; + pos = lineEnd; + if (pos < raw.length && raw[pos] == '\r') pos++; + if (pos < raw.length && raw[pos] == '\n') pos++; + size_t end = pos + cast(size_t) chunkSize; + if (end > raw.length) end = raw.length; + result ~= raw[pos .. end]; + pos = end; + if (pos < raw.length && raw[pos] == '\r') pos++; + if (pos < raw.length && raw[pos] == '\n') pos++; + } + return result; +} + // --- Route handlers --- void pipelineHandler(ref HttpRequestContext ctx) { @@ -140,8 +204,6 @@ void pipelineHandler(ref HttpRequestContext ctx) { } void baseline11Handler(ref HttpRequestContext ctx) { - string query = ctx.request.url.length > 0 ? "" : ""; - // Extract query string from the raw URL long sum = 0; // Parse query params from the request @@ -151,10 +213,19 @@ void baseline11Handler(ref HttpRequestContext ctx) { } catch (Exception e) {} } - // If POST, also read body + // If POST, also read body (handle both regular and chunked TE) if (ctx.request.method == Method.POST) { try { - string body = ctx.request.readBodyAsString().strip(); + string rawBody = ctx.request.readBodyAsString(); + // If chunked TE, body may contain chunk framing — extract payload + string te = ctx.request.headers.getFirst("Transfer-Encoding").orElse(""); + import std.algorithm : canFind; + string body; + if (te.canFind("chunked") && rawBody.length > 0) { + body = decodeChunkedPayload(rawBody).strip(); + } else { + body = rawBody.strip(); + } if (body.length > 0) { sum += body.to!long; } @@ -205,7 +276,15 @@ void compressionHandler(ref HttpRequestContext ctx) { } void uploadHandler(ref HttpRequestContext ctx) { - ubyte[] body = ctx.request.readBodyAsBytes(); + ubyte[] rawBody = ctx.request.readBodyAsBytes(); + ubyte[] body; + string te = ctx.request.headers.getFirst("Transfer-Encoding").orElse(""); + import std.algorithm : canFind; + if (te.canFind("chunked") && rawBody.length > 0) { + body = decodeChunkedBytes(rawBody); + } else { + body = rawBody; + } ctx.response.addHeader("Server", SERVER_NAME); ctx.response.writeBodyString(body.length.to!string, "text/plain"); } From 71d966dae1da27bf29c5cdb40fa64d26525ec290 Mon Sep 17 00:00:00 2001 From: BennyFranciscus <268274351+BennyFranciscus@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:06:13 +0000 Subject: [PATCH 6/9] Fix chunked body: try direct parse first, fallback to chunked decode handy-httpd v8 may or may not decode chunked TE internally. Try parsing the body directly first; only attempt manual chunked decoding if that fails. --- frameworks/handy-httpd/src/app.d | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/frameworks/handy-httpd/src/app.d b/frameworks/handy-httpd/src/app.d index 9588f29f..ceac3e2d 100644 --- a/frameworks/handy-httpd/src/app.d +++ b/frameworks/handy-httpd/src/app.d @@ -216,18 +216,24 @@ void baseline11Handler(ref HttpRequestContext ctx) { // If POST, also read body (handle both regular and chunked TE) if (ctx.request.method == Method.POST) { try { - string rawBody = ctx.request.readBodyAsString(); - // If chunked TE, body may contain chunk framing — extract payload - string te = ctx.request.headers.getFirst("Transfer-Encoding").orElse(""); - import std.algorithm : canFind; - string body; - if (te.canFind("chunked") && rawBody.length > 0) { - body = decodeChunkedPayload(rawBody).strip(); - } else { - body = rawBody.strip(); - } - if (body.length > 0) { - sum += body.to!long; + string rawBody = ctx.request.readBodyAsString().strip(); + if (rawBody.length > 0) { + // Try parsing directly first (works if library already decoded chunked TE) + bool parsed = false; + try { + sum += rawBody.to!long; + parsed = true; + } catch (Exception e) {} + // If direct parse failed and chunked TE, try decoding chunk framing + if (!parsed) { + string te = ctx.request.headers.getFirst("Transfer-Encoding").orElse(""); + import std.algorithm : canFind; + if (te.canFind("chunked")) { + string decoded = decodeChunkedPayload(rawBody).strip(); + if (decoded.length > 0) + sum += decoded.to!long; + } + } } } catch (Exception e) {} } @@ -281,7 +287,9 @@ void uploadHandler(ref HttpRequestContext ctx) { string te = ctx.request.headers.getFirst("Transfer-Encoding").orElse(""); import std.algorithm : canFind; if (te.canFind("chunked") && rawBody.length > 0) { + // Try chunked decode; if result is empty, library may have already decoded body = decodeChunkedBytes(rawBody); + if (body.length == 0) body = rawBody; } else { body = rawBody; } From ca0c8fd69850e43d0f676291913c8d2bb49bebc4 Mon Sep 17 00:00:00 2001 From: BennyFranciscus <268274351+BennyFranciscus@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:11:30 +0000 Subject: [PATCH 7/9] fix: use readBodyAsBytes for chunked TE in baseline11 readBodyAsString() returns empty/partial data for chunked Transfer-Encoding. Switch to readBodyAsBytes() + string conversion, same approach as upload handler which already works correctly. --- frameworks/handy-httpd/src/app.d | 35 +++++++++++++++----------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/frameworks/handy-httpd/src/app.d b/frameworks/handy-httpd/src/app.d index ceac3e2d..5d9b2146 100644 --- a/frameworks/handy-httpd/src/app.d +++ b/frameworks/handy-httpd/src/app.d @@ -213,27 +213,24 @@ void baseline11Handler(ref HttpRequestContext ctx) { } catch (Exception e) {} } - // If POST, also read body (handle both regular and chunked TE) + // If POST, also read body (use readBodyAsBytes for chunked TE support) if (ctx.request.method == Method.POST) { try { - string rawBody = ctx.request.readBodyAsString().strip(); - if (rawBody.length > 0) { - // Try parsing directly first (works if library already decoded chunked TE) - bool parsed = false; - try { - sum += rawBody.to!long; - parsed = true; - } catch (Exception e) {} - // If direct parse failed and chunked TE, try decoding chunk framing - if (!parsed) { - string te = ctx.request.headers.getFirst("Transfer-Encoding").orElse(""); - import std.algorithm : canFind; - if (te.canFind("chunked")) { - string decoded = decodeChunkedPayload(rawBody).strip(); - if (decoded.length > 0) - sum += decoded.to!long; - } - } + ubyte[] rawBytes = ctx.request.readBodyAsBytes(); + ubyte[] bodyBytes; + string te = ctx.request.headers.getFirst("Transfer-Encoding").orElse(""); + import std.algorithm : canFind; + if (te.canFind("chunked") && rawBytes.length > 0) { + // Try chunked decode first (if library passed through raw framing) + bodyBytes = decodeChunkedBytes(rawBytes); + if (bodyBytes.length == 0) bodyBytes = rawBytes; + } else { + bodyBytes = rawBytes; + } + if (bodyBytes.length > 0) { + string bodyStr = (cast(string) bodyBytes).strip(); + if (bodyStr.length > 0) + sum += bodyStr.to!long; } } catch (Exception e) {} } From fb01a84ff1d56a18b8dfc78eb66f2000982cc385 Mon Sep 17 00:00:00 2001 From: BennyFranciscus <268274351+BennyFranciscus@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:27:37 +0000 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20remove=20manual=20chunked=20decoder?= =?UTF-8?q?=20=E2=80=94=20handy-httpd=20v8=20handles=20chunked=20TE=20inte?= =?UTF-8?q?rnally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The readBodyAsBytes()/readBodyAsString() methods in handy-httpd v8 use chunkedEncodingInputStreamFor() to decode chunked Transfer-Encoding before returning the body. My manual decoder was double-decoding: '20' (the body) was being parsed as hex chunk size 0x20=32, reading 32 bytes from a 2-byte input, returning empty → body treated as 0. Simplified both baseline11 and upload handlers to just use the library's built-in body reading directly. --- frameworks/handy-httpd/src/app.d | 98 ++------------------------------ 1 file changed, 6 insertions(+), 92 deletions(-) diff --git a/frameworks/handy-httpd/src/app.d b/frameworks/handy-httpd/src/app.d index 5d9b2146..c6f32055 100644 --- a/frameworks/handy-httpd/src/app.d +++ b/frameworks/handy-httpd/src/app.d @@ -132,70 +132,6 @@ void loadStaticFiles() { } catch (Exception e) {} } -// --- Chunked TE decoder --- - -string decodeChunkedPayload(string raw) { - string result; - size_t pos = 0; - while (pos < raw.length) { - // Find the end of the chunk size line - size_t lineEnd = pos; - while (lineEnd < raw.length && raw[lineEnd] != '\r' && raw[lineEnd] != '\n') - lineEnd++; - if (lineEnd == pos) break; - string sizeStr = raw[pos .. lineEnd].strip(); - long chunkSize; - try { - chunkSize = sizeStr.to!long(16); - } catch (Exception e) { - break; - } - if (chunkSize == 0) break; - // Skip past \r\n - pos = lineEnd; - if (pos < raw.length && raw[pos] == '\r') pos++; - if (pos < raw.length && raw[pos] == '\n') pos++; - // Read chunk data - size_t end = pos + cast(size_t) chunkSize; - if (end > raw.length) end = raw.length; - result ~= raw[pos .. end]; - pos = end; - // Skip trailing \r\n - if (pos < raw.length && raw[pos] == '\r') pos++; - if (pos < raw.length && raw[pos] == '\n') pos++; - } - return result; -} - -ubyte[] decodeChunkedBytes(ubyte[] raw) { - ubyte[] result; - size_t pos = 0; - while (pos < raw.length) { - size_t lineEnd = pos; - while (lineEnd < raw.length && raw[lineEnd] != '\r' && raw[lineEnd] != '\n') - lineEnd++; - if (lineEnd == pos) break; - string sizeStr = (cast(string) raw[pos .. lineEnd]).strip(); - long chunkSize; - try { - chunkSize = sizeStr.to!long(16); - } catch (Exception e) { - break; - } - if (chunkSize == 0) break; - pos = lineEnd; - if (pos < raw.length && raw[pos] == '\r') pos++; - if (pos < raw.length && raw[pos] == '\n') pos++; - size_t end = pos + cast(size_t) chunkSize; - if (end > raw.length) end = raw.length; - result ~= raw[pos .. end]; - pos = end; - if (pos < raw.length && raw[pos] == '\r') pos++; - if (pos < raw.length && raw[pos] == '\n') pos++; - } - return result; -} - // --- Route handlers --- void pipelineHandler(ref HttpRequestContext ctx) { @@ -213,25 +149,12 @@ void baseline11Handler(ref HttpRequestContext ctx) { } catch (Exception e) {} } - // If POST, also read body (use readBodyAsBytes for chunked TE support) + // If POST, read body — handy-httpd v8 decodes chunked TE internally if (ctx.request.method == Method.POST) { try { - ubyte[] rawBytes = ctx.request.readBodyAsBytes(); - ubyte[] bodyBytes; - string te = ctx.request.headers.getFirst("Transfer-Encoding").orElse(""); - import std.algorithm : canFind; - if (te.canFind("chunked") && rawBytes.length > 0) { - // Try chunked decode first (if library passed through raw framing) - bodyBytes = decodeChunkedBytes(rawBytes); - if (bodyBytes.length == 0) bodyBytes = rawBytes; - } else { - bodyBytes = rawBytes; - } - if (bodyBytes.length > 0) { - string bodyStr = (cast(string) bodyBytes).strip(); - if (bodyStr.length > 0) - sum += bodyStr.to!long; - } + string bodyStr = ctx.request.readBodyAsString().strip(); + if (bodyStr.length > 0) + sum += bodyStr.to!long; } catch (Exception e) {} } @@ -279,17 +202,8 @@ void compressionHandler(ref HttpRequestContext ctx) { } void uploadHandler(ref HttpRequestContext ctx) { - ubyte[] rawBody = ctx.request.readBodyAsBytes(); - ubyte[] body; - string te = ctx.request.headers.getFirst("Transfer-Encoding").orElse(""); - import std.algorithm : canFind; - if (te.canFind("chunked") && rawBody.length > 0) { - // Try chunked decode; if result is empty, library may have already decoded - body = decodeChunkedBytes(rawBody); - if (body.length == 0) body = rawBody; - } else { - body = rawBody; - } + // handy-httpd v8 decodes chunked TE internally + ubyte[] body = ctx.request.readBodyAsBytes(); ctx.response.addHeader("Server", SERVER_NAME); ctx.response.writeBodyString(body.length.to!string, "text/plain"); } From 77b6a13d354c183933faf861b9d55d24adae7b88 Mon Sep 17 00:00:00 2001 From: BennyFranciscus <268274351+BennyFranciscus@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:31:42 +0000 Subject: [PATCH 9/9] fix(handy-httpd): pass allowInfiniteRead=true for chunked TE support handy-httpd's readBody() refuses to read when Content-Length is absent (returns 0 bytes) unless allowInfiniteRead=true. Chunked Transfer-Encoding requests don't have Content-Length, so readBodyAsString() and readBodyAsBytes() were returning empty data. This caused 'POST /baseline11 chunked body=20' to fail: the body '20' was never read, so sum was just a+b=55 instead of a+b+body=75. --- frameworks/handy-httpd/src/app.d | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frameworks/handy-httpd/src/app.d b/frameworks/handy-httpd/src/app.d index c6f32055..e9097ff9 100644 --- a/frameworks/handy-httpd/src/app.d +++ b/frameworks/handy-httpd/src/app.d @@ -149,10 +149,13 @@ void baseline11Handler(ref HttpRequestContext ctx) { } catch (Exception e) {} } - // If POST, read body — handy-httpd v8 decodes chunked TE internally + // If POST, read body — handy-httpd v8 decodes chunked TE internally. + // Must pass allowInfiniteRead=true because chunked requests have no + // Content-Length header, and readBody() refuses to read without one + // unless allowInfiniteRead is set. if (ctx.request.method == Method.POST) { try { - string bodyStr = ctx.request.readBodyAsString().strip(); + string bodyStr = ctx.request.readBodyAsString(true).strip(); if (bodyStr.length > 0) sum += bodyStr.to!long; } catch (Exception e) {} @@ -202,8 +205,9 @@ void compressionHandler(ref HttpRequestContext ctx) { } void uploadHandler(ref HttpRequestContext ctx) { - // handy-httpd v8 decodes chunked TE internally - ubyte[] body = ctx.request.readBodyAsBytes(); + // handy-httpd v8 decodes chunked TE internally. + // allowInfiniteRead=true needed for chunked TE (no Content-Length). + ubyte[] body = ctx.request.readBodyAsBytes(true); ctx.response.addHeader("Server", SERVER_NAME); ctx.response.writeBodyString(body.length.to!string, "text/plain"); }