From 7ac8480513505ceb71e47ecc39080dfa0fca9077 Mon Sep 17 00:00:00 2001 From: virskl Date: Mon, 24 Aug 2026 12:46:06 +0200 Subject: [PATCH] Serve the unchanging files from one table The five files a clock hands out as they are - the two pages, the manifest and the two icons - had a handler each, in both backends. Ten functions that differed only in a blob, a content type, and whether a Content-Encoding header went out with it. That is data, so it is a table now: WebFrontend::AssetType, walked by each backend to register one route per entry. The bytes moved with it. WebPage.h is generated with internal linkage, so it is included by WebFrontend.cpp and by nothing else - a second includer would put a second copy of both pages in flash. Neither WebInterface.cpp includes it any more. Where the two servers differ is how a route carries which entry it answers for: the IDF gives a handler a user context per route, so the ESP32 registers an array of httpd_uri_t pointing into the table, while ESPAsyncWebServer takes a std::function and the RP2350 captures the entry instead. Both loops end at WebFrontend::MaxAssets, which a static_assert in the table's own source holds against the real count - adding a file without raising it fails the build rather than registering the first five of six. The ESP32's host test had to learn that a route is a handler *and* a context: with one shared asset handler, capturing the function pointer alone made every asset answer as whichever registered last. Measured, and the reason to measure was the duplicated-blob risk above: flash went *down* on both boards - 1 100 421 to 1 100 233 on the ESP32, 501 332 to 500 924 on the Pico - so nothing is stored twice and the table costs less than the handlers did. The ESP32 gains 120 bytes of RAM for the static route array. The AVR-Dx image is byte-identical at 48078/1700, all four targets build warning-free, both host suites pass, and the documented sizes still check out. The panel was driven in a browser again: 19 of 111 cells lit, "ES IST VIERTEL VOR EINS", brightness read back over the socket. Worth noting that this does not exercise the new table - serve.js answers the asset routes from disk itself - so what covers it is web_test.cpp on both backends. Co-Authored-By: Claude Fable 5 --- .../Communication/WebFrontend/WebFrontend.h | 31 +++++ .../Communication/WebFrontend/WebFrontend.cpp | 58 ++++++++ platform/esp32/src/WebInterface.cpp | 127 +++++------------- platform/esp32/test/cases/web_test.cpp | 57 +++++--- platform/rp2350/src/WebInterface.cpp | 109 ++++----------- 5 files changed, 183 insertions(+), 199 deletions(-) diff --git a/firmware/inc/Communication/WebFrontend/WebFrontend.h b/firmware/inc/Communication/WebFrontend/WebFrontend.h index 18a64ef..40929d9 100644 --- a/firmware/inc/Communication/WebFrontend/WebFrontend.h +++ b/firmware/inc/Communication/WebFrontend/WebFrontend.h @@ -74,6 +74,26 @@ ******************************************************************************************************************************************************/ class WebFrontend { + public: + /* One file a clock hands out unchanged: the two pages, the manifest and the two icons. + Everything a backend needs in order to answer for it, and nothing about how - which is + why this is a table and not five handlers. It was five handlers, twice, and the ten + differed only in a blob, a content type and whether a header went out with it. + + The bytes live in the generated WebPage.h, which only WebFrontend.cpp includes: the + generated arrays have internal linkage, so a second includer would put a second copy + of both pages in flash. */ + struct AssetType { + const char* Path; + const uint8_t* Bytes; + size_t Size; + const char* ContentType; + /* Stored compressed and sent compressed, so the backend has to say so in a header + and the browser unpacks it. False for the manifest, which is 485 bytes, and for + the icons, a PNG being deflated already. */ + bool IsGzipped; + }; + /****************************************************************************************************************************************************** * P R I V A T E D A T A A N D F U N C T I O N S ******************************************************************************************************************************************************/ @@ -107,6 +127,17 @@ class WebFrontend } // methods + /* What a backend may size a static array of routes with, since the count itself is only + known in the source. A static_assert there holds the two together, so a file added to + the table without raising this fails the build rather than one registration. */ + static constexpr byte MaxAssets{5u}; + + /* The files a clock serves unchanged, for a backend to register a route per entry and + answer it from the table. Walked rather than named one by one, so a file added to + web/ reaches both backends by appearing here. */ + static byte getNumberOfAssets(); + static const AssetType& getAsset(byte Index); + /* The command catalog as JSON, so a page can build its own form from it, and the panel's shape and letters. Both take the body a backend's route handler opened, which is the only thing about them that is a backend's business. */ diff --git a/firmware/src/Communication/WebFrontend/WebFrontend.cpp b/firmware/src/Communication/WebFrontend/WebFrontend.cpp index 5d542a7..da4ceca 100644 --- a/firmware/src/Communication/WebFrontend/WebFrontend.cpp +++ b/firmware/src/Communication/WebFrontend/WebFrontend.cpp @@ -24,6 +24,10 @@ #include "DisplayCharacters.h" #include "MessageCatalog.h" #include "Pixels.h" +/* The build generates this from web/. Included here and nowhere else: its arrays have + internal linkage, so every further includer would be a second copy of both pages in + flash. */ +#include "WebPage.h" #include "WordclockSerial.h" #include @@ -130,10 +134,64 @@ class ChunkWriter } // namespace +/****************************************************************************************************************************************************** + * L O C A L D A T A +******************************************************************************************************************************************************/ +namespace { + +/* Every file a clock hands out unchanged, at the path it hands it out at. + * + * "/" is the panel and "/console" the console, which is the whole arrangement rather than a + * detail: nobody types a path, they type the clock's address and take what comes, so what + * comes is the page for the things somebody changes often, with a link to the other one in + * its header. + * + * The three that follow are neither page but the home screen icon - a manifest and two PNGs - + * which is what lets the console be installed with an icon to tap and a start without an + * address bar. + */ +constexpr WebFrontend::AssetType Assets[]{ + {"/", WebAppGzip, WebAppGzipSize, "text/html", true}, + {"/console", WebPageGzip, WebPageGzipSize, "text/html", true}, + {"/manifest.webmanifest", WebManifest, WebManifestSize, "application/manifest+json", false}, + {"/icon-192.png", WebIcon192, WebIcon192Size, "image/png", false}, + {"/icon-512.png", WebIcon512, WebIcon512Size, "image/png", false}, +}; + +/* A backend sizes its route array with MaxAssets, which it can see and this count it cannot. + Adding a file here without raising that fails the build, rather than quietly registering + the first five of six. */ +static_assert((sizeof(Assets) / sizeof(Assets[0])) <= WebFrontend::MaxAssets, + "WebFrontend::MaxAssets has to grow with the asset table"); + +} // namespace + /****************************************************************************************************************************************************** * P U B L I C F U N C T I O N S ******************************************************************************************************************************************************/ +/****************************************************************************************************************************************************** + getNumberOfAssets() / getAsset() +******************************************************************************************************************************************************/ +/*! \brief The files a clock serves unchanged, for a backend to register routes from + * + * \details A table rather than a handler per file, because that is what the ten + * handlers this replaces actually were: each one set a content type, set a + * Content-Encoding header or did not, and pushed a blob. The difference + * between them was data, and the sameness between the two backends was + * complete. +******************************************************************************************************************************************************/ +byte WebFrontend::getNumberOfAssets() +{ + return static_cast(sizeof(Assets) / sizeof(Assets[0])); +} + +const WebFrontend::AssetType& WebFrontend::getAsset(byte Index) +{ + return Assets[Index]; +} + + /****************************************************************************************************************************************************** writeCommands() ******************************************************************************************************************************************************/ diff --git a/platform/esp32/src/WebInterface.cpp b/platform/esp32/src/WebInterface.cpp index 7a08e47..478f870 100644 --- a/platform/esp32/src/WebInterface.cpp +++ b/platform/esp32/src/WebInterface.cpp @@ -26,7 +26,6 @@ #include "System.h" #include "WebFrontend.h" #include "WebInterface.h" -#include "WebPage.h" #include "WebTransport.h" #include "WordclockSerial.h" @@ -150,71 +149,30 @@ bool isRequestAuthorised(httpd_req_t* Request) /****************************************************************************************************************************************************** - sendPage() + handleAsset() ******************************************************************************************************************************************************/ -/*! \brief Serves one of the two pages straight out of flash - * \details Sent compressed, which is how it is stored: the pages were gzipped at build - * time, so this only pushes the bytes and the browser unpacks them. +/*! \brief Serves one of the files the clock hands out unchanged + * + * \details One handler for all five - the two pages, the manifest and the two icons - + * because what separates them is data and the data is WebFrontend's table. + * Which entry this request is for arrives in the server's own user context, + * which is what that field exists for. + * + * A gzipped asset is sent as it is stored and the browser unpacks it; that + * is the whole of what the header does here, and the table says which. + * + * \return ESP_OK ******************************************************************************************************************************************************/ -esp_err_t sendPage(httpd_req_t* Request, const uint8_t* Page, size_t Size) +esp_err_t handleAsset(httpd_req_t* Request) { if(!isRequestAuthorised(Request)) { return ESP_OK; } - httpd_resp_set_type(Request, "text/html"); - httpd_resp_set_hdr(Request, "Content-Encoding", "gzip"); - - return httpd_resp_send(Request, reinterpret_cast(Page), Size); -} + const WebFrontend::AssetType& Asset = *static_cast(Request->user_ctx); + httpd_resp_set_type(Request, Asset.ContentType); + if(Asset.IsGzipped) { httpd_resp_set_hdr(Request, "Content-Encoding", "gzip"); } -/****************************************************************************************************************************************************** - handleRoot() -******************************************************************************************************************************************************/ -/*! \brief Serves the page a clock hands out at "/" - * \details The one made for what somebody changes: a colour, a brightness, an - * animation. Nobody types a path - they type the clock's address and take - * what comes - so what comes is the page for the frequent things, and the - * console is one link away. -******************************************************************************************************************************************************/ -esp_err_t handleRoot(httpd_req_t* Request) -{ - return sendPage(Request, WebAppGzip, WebAppGzipSize); -} - - -/****************************************************************************************************************************************************** - handleConsole() -******************************************************************************************************************************************************/ -/*! \brief Serves the console at "/console" - * \details Everything the page at "/" does not cover, which is most of the command - * set: it draws a group per command out of the catalog, so a command added to - * the firmware appears there without a page being touched. That is why it - * moved rather than being replaced. -******************************************************************************************************************************************************/ -esp_err_t handleConsole(httpd_req_t* Request) -{ - return sendPage(Request, WebPageGzip, WebPageGzipSize); -} - - -/****************************************************************************************************************************************************** - handleManifest() -******************************************************************************************************************************************************/ -/*! \brief Serves the web app manifest, which is what makes the console installable - * \details Nothing on the clock reads it - a browser does, to put the console on a - * home screen with an icon and start it without an address bar. Sent as it - * is, unlike the page: it is 485 bytes, and compressing it would save - * fewer than 250 of them for a header on the wire and an inflate in - * anything that wants to read it - curl and this backend's own test - * included. -******************************************************************************************************************************************************/ -esp_err_t handleManifest(httpd_req_t* Request) -{ - if(!isRequestAuthorised(Request)) { return ESP_OK; } - - httpd_resp_set_type(Request, "application/manifest+json"); - - return httpd_resp_send(Request, reinterpret_cast(WebManifest), WebManifestSize); + return httpd_resp_send(Request, reinterpret_cast(Asset.Bytes), Asset.Size); } @@ -324,32 +282,6 @@ esp_err_t handleUpdate(httpd_req_t* Request) } -/****************************************************************************************************************************************************** - handleIcon192() / handleIcon512() -******************************************************************************************************************************************************/ -/*! \brief Serves the home screen icons - * \details Two sizes because that is what a manifest is asked for: the small one is - * the icon itself and what iOS takes, the large one is what Android draws - * the splash screen from. Sent as they are - a PNG is already deflated, so - * gzipping one would add a header and save nothing. -******************************************************************************************************************************************************/ -esp_err_t handleIcon192(httpd_req_t* Request) -{ - if(!isRequestAuthorised(Request)) { return ESP_OK; } - - httpd_resp_set_type(Request, "image/png"); - - return httpd_resp_send(Request, reinterpret_cast(WebIcon192), WebIcon192Size); -} - -esp_err_t handleIcon512(httpd_req_t* Request) -{ - if(!isRequestAuthorised(Request)) { return ESP_OK; } - - httpd_resp_set_type(Request, "image/png"); - - return httpd_resp_send(Request, reinterpret_cast(WebIcon512), WebIcon512Size); -} /****************************************************************************************************************************************************** @@ -491,23 +423,28 @@ StdReturnType WebInterface::begin() return E_NOT_OK; } - static const httpd_uri_t RootUri{"/", HTTP_GET, handleRoot, nullptr, false, false, nullptr}; - static const httpd_uri_t ConsoleUri{"/console", HTTP_GET, handleConsole, nullptr, false, false, nullptr}; + /* The files served unchanged, one route per entry of WebFrontend's table. The array is + static because httpd keeps the pointer it is registered with rather than a copy, and + sized from the table so that a file added to web/ needs no edit here - only + max_uri_handlers above has to still be large enough, which is why that number is said + out loud rather than left at its default. */ + static httpd_uri_t AssetUris[WebFrontend::MaxAssets]{}; + + for(byte Index = 0u; Index < WebFrontend::getNumberOfAssets(); Index++) { + const WebFrontend::AssetType& Asset = WebFrontend::getAsset(Index); + + AssetUris[Index] = httpd_uri_t{Asset.Path, HTTP_GET, handleAsset, const_cast(&Asset), + false, false, nullptr}; + httpd_register_uri_handler(HttpServer, &AssetUris[Index]); + } + static const httpd_uri_t CommandsUri{"/commands", HTTP_GET, handleCommands, nullptr, false, false, nullptr}; static const httpd_uri_t DisplayUri{"/display", HTTP_GET, handleDisplay, nullptr, false, false, nullptr}; - static const httpd_uri_t ManifestUri{"/manifest.webmanifest", HTTP_GET, handleManifest, nullptr, false, false, nullptr}; - static const httpd_uri_t Icon192Uri{"/icon-192.png", HTTP_GET, handleIcon192, nullptr, false, false, nullptr}; - static const httpd_uri_t Icon512Uri{"/icon-512.png", HTTP_GET, handleIcon512, nullptr, false, false, nullptr}; static const httpd_uri_t UpdateUri{"/update", HTTP_POST, handleUpdate, nullptr, false, false, nullptr}; static const httpd_uri_t SocketUri{"/ws", HTTP_GET, handleSocket, nullptr, true, false, nullptr}; - httpd_register_uri_handler(HttpServer, &RootUri); - httpd_register_uri_handler(HttpServer, &ConsoleUri); httpd_register_uri_handler(HttpServer, &CommandsUri); httpd_register_uri_handler(HttpServer, &DisplayUri); - httpd_register_uri_handler(HttpServer, &ManifestUri); - httpd_register_uri_handler(HttpServer, &Icon192Uri); - httpd_register_uri_handler(HttpServer, &Icon512Uri); httpd_register_uri_handler(HttpServer, &UpdateUri); httpd_register_uri_handler(HttpServer, &SocketUri); diff --git a/platform/esp32/test/cases/web_test.cpp b/platform/esp32/test/cases/web_test.cpp index 0b28073..234cfee 100644 --- a/platform/esp32/test/cases/web_test.cpp +++ b/platform/esp32/test/cases/web_test.cpp @@ -17,15 +17,34 @@ #include -static httpd_handler_t SocketHandler = nullptr; -static httpd_handler_t RootHandler = nullptr; -static httpd_handler_t CommandsHandler = nullptr; -static httpd_handler_t DisplayHandler = nullptr; -static httpd_handler_t ManifestHandler = nullptr; -static httpd_handler_t Icon192Handler = nullptr; -static httpd_handler_t Icon512Handler = nullptr; -static httpd_handler_t UpdateHandler = nullptr; -static httpd_handler_t ConsoleHandler = nullptr; +/* A registered route as the server keeps it: the handler and the context handed back with + it. The context matters since the five files served unchanged share one handler and tell + each other apart by it - so a route is called through here rather than as a bare function + pointer, or every asset would answer as whichever was registered last. */ +struct RouteType { + httpd_handler_t Handler = nullptr; + void* Context = nullptr; + + esp_err_t operator()(httpd_req_t* Request) const { + Request->user_ctx = Context; + return Handler(Request); + } + + /* So a case can still ask whether a route was registered at all, which is what the + nullptr comparisons below are for. */ + bool operator!=(std::nullptr_t) const { return Handler != nullptr; } + bool operator==(std::nullptr_t) const { return Handler == nullptr; } +}; + +static RouteType SocketHandler; +static RouteType RootHandler; +static RouteType CommandsHandler; +static RouteType DisplayHandler; +static RouteType ManifestHandler; +static RouteType Icon192Handler; +static RouteType Icon512Handler; +static RouteType UpdateHandler; +static RouteType ConsoleHandler; static std::string SentType; static std::string SentStatus; /* Every header a handler set, by name - the gate's WWW-Authenticate is read out of here. */ @@ -45,15 +64,17 @@ esp_err_t httpd_start(httpd_handle_t* h, const httpd_config_t*) { *h = (httpd_ha esp_err_t httpd_register_uri_handler(httpd_handle_t, const httpd_uri_t* u) { - if(strcmp(u->uri, "/ws") == 0) { SocketHandler = u->handler; } - else if(strcmp(u->uri, "/commands") == 0) { CommandsHandler = u->handler; } - else if(strcmp(u->uri, "/display") == 0) { DisplayHandler = u->handler; } - else if(strcmp(u->uri, "/manifest.webmanifest") == 0) { ManifestHandler = u->handler; } - else if(strcmp(u->uri, "/icon-192.png") == 0) { Icon192Handler = u->handler; } - else if(strcmp(u->uri, "/icon-512.png") == 0) { Icon512Handler = u->handler; } - else if(strcmp(u->uri, "/update") == 0) { UpdateHandler = u->handler; } - else if(strcmp(u->uri, "/console") == 0) { ConsoleHandler = u->handler; } - else { RootHandler = u->handler; } + const RouteType Route{u->handler, u->user_ctx}; + + if(strcmp(u->uri, "/ws") == 0) { SocketHandler = Route; } + else if(strcmp(u->uri, "/commands") == 0) { CommandsHandler = Route; } + else if(strcmp(u->uri, "/display") == 0) { DisplayHandler = Route; } + else if(strcmp(u->uri, "/manifest.webmanifest") == 0) { ManifestHandler = Route; } + else if(strcmp(u->uri, "/icon-192.png") == 0) { Icon192Handler = Route; } + else if(strcmp(u->uri, "/icon-512.png") == 0) { Icon512Handler = Route; } + else if(strcmp(u->uri, "/update") == 0) { UpdateHandler = Route; } + else if(strcmp(u->uri, "/console") == 0) { ConsoleHandler = Route; } + else { RootHandler = Route; } return ESP_OK; } diff --git a/platform/rp2350/src/WebInterface.cpp b/platform/rp2350/src/WebInterface.cpp index 46433fe..f21e9bf 100644 --- a/platform/rp2350/src/WebInterface.cpp +++ b/platform/rp2350/src/WebInterface.cpp @@ -29,7 +29,6 @@ #include "System.h" #include "WebFrontend.h" #include "WebInterface.h" -#include "WebPage.h" #include "WebTransport.h" #include "WordclockSerial.h" @@ -229,72 +228,29 @@ void commitImage(size_t Announced) /****************************************************************************************************************************************************** - sendPage() + handleAsset() ******************************************************************************************************************************************************/ -/*! \brief Serves one of the two pages straight out of flash - * \details Sent compressed, which is how it is stored: the pages were gzipped at build - * time, so this only pushes the bytes and the browser unpacks them. +/*! \brief Serves one of the files the clock hands out unchanged + * + * \details One handler for all five - the two pages, the manifest and the two icons - + * because what separates them is data and the data is WebFrontend's table. + * Where the IDF's server carries a user context per route, this library takes + * a std::function, so the entry is captured instead. + * + * A gzipped asset is sent as it is stored and the browser unpacks it; that + * is the whole of what the header does here, and the table says which. ******************************************************************************************************************************************************/ -void sendPage(AsyncWebServerRequest* Request, const uint8_t* Page, size_t Size) +void handleAsset(AsyncWebServerRequest* Request, const WebFrontend::AssetType& Asset) { if(!isRequestAuthorised(Request)) { return; } - AsyncWebServerResponse* Response = Request->beginResponse(200, "text/html", Page, Size); + AsyncWebServerResponse* Response = Request->beginResponse(200, Asset.ContentType, Asset.Bytes, Asset.Size); - Response->addHeader("Content-Encoding", "gzip"); + if(Asset.IsGzipped) { Response->addHeader("Content-Encoding", "gzip"); } Request->send(Response); } -/****************************************************************************************************************************************************** - handleRoot() -******************************************************************************************************************************************************/ -/*! \brief Serves the page a clock hands out at "/" - * \details The one made for what somebody changes: a colour, a brightness, an - * animation. Nobody types a path - they type the clock's address and take what - * comes - so what comes is the page for the frequent things, and the console is - * one link away at "/console". -******************************************************************************************************************************************************/ -void handleRoot(AsyncWebServerRequest* Request) -{ - sendPage(Request, WebAppGzip, WebAppGzipSize); -} - - -/****************************************************************************************************************************************************** - handleConsole() -******************************************************************************************************************************************************/ -/*! \brief Serves the console at "/console" - * \details Everything the page at "/" does not cover, which is most of the command set: - * it draws a group per command out of the catalog, so a command added to the - * firmware appears there without a page being touched. That is why it moved - * rather than being replaced. -******************************************************************************************************************************************************/ -void handleConsole(AsyncWebServerRequest* Request) -{ - sendPage(Request, WebPageGzip, WebPageGzipSize); -} - - -/****************************************************************************************************************************************************** - handleManifest() -******************************************************************************************************************************************************/ -/*! \brief Serves the web app manifest, which is what makes the console installable - * \details Nothing on the clock reads it - a browser does, to put the console on a - * home screen with an icon and start it without an address bar. Sent as it - * is, unlike the page: it is 485 bytes, and compressing it would save - * fewer than 250 of them for a header on the wire and an inflate in - * anything that wants to read it - curl and this backend's own test - * included. -******************************************************************************************************************************************************/ -void handleManifest(AsyncWebServerRequest* Request) -{ - if(!isRequestAuthorised(Request)) { return; } - - Request->send(Request->beginResponse(200, "application/manifest+json", WebManifest, WebManifestSize)); -} - - /****************************************************************************************************************************************************** sendUpdateResult() ******************************************************************************************************************************************************/ @@ -395,30 +351,6 @@ void handleUpdate(AsyncWebServerRequest* Request) } -/****************************************************************************************************************************************************** - handleIcon192() / handleIcon512() -******************************************************************************************************************************************************/ -/*! \brief Serves the home screen icons - * \details Two sizes because that is what a manifest is asked for: the small one is - * the icon itself and what iOS takes, the large one is what Android draws - * the splash screen from. Sent as they are - a PNG is already deflated, so - * gzipping one would add a header and save nothing. -******************************************************************************************************************************************************/ -void handleIcon192(AsyncWebServerRequest* Request) -{ - if(!isRequestAuthorised(Request)) { return; } - - Request->send(Request->beginResponse(200, "image/png", WebIcon192, WebIcon192Size)); -} - -void handleIcon512(AsyncWebServerRequest* Request) -{ - if(!isRequestAuthorised(Request)) { return; } - - Request->send(Request->beginResponse(200, "image/png", WebIcon512, WebIcon512Size)); -} - - /****************************************************************************************************************************************************** handleCommands() / handleDisplay() ******************************************************************************************************************************************************/ @@ -509,13 +441,18 @@ StdReturnType WebInterface::begin() Socket.onEvent(onSocketEvent); HttpServer.addHandler(&Socket); - HttpServer.on("/", HTTP_GET, handleRoot); - HttpServer.on("/console", HTTP_GET, handleConsole); + /* The files served unchanged, one route per entry of WebFrontend's table, so a file added + to web/ needs no edit here. The entry is captured by reference and outlives the lambda: + the table has static storage duration. */ + for(byte Index = 0u; Index < WebFrontend::getNumberOfAssets(); Index++) { + const WebFrontend::AssetType& Asset = WebFrontend::getAsset(Index); + + HttpServer.on(Asset.Path, HTTP_GET, + [&Asset](AsyncWebServerRequest* Request) { handleAsset(Request, Asset); }); + } + HttpServer.on("/commands", HTTP_GET, handleCommands); HttpServer.on("/display", HTTP_GET, handleDisplay); - HttpServer.on("/manifest.webmanifest", HTTP_GET, handleManifest); - HttpServer.on("/icon-192.png", HTTP_GET, handleIcon192); - HttpServer.on("/icon-512.png", HTTP_GET, handleIcon512); /* Four arguments, and the fourth is where the image arrives: the library hands a POST body to that callback in chunks and calls the request handler afterwards. The third, the upload handler, is for multipart forms and stays empty - the panel sends the file