From a35c94e14a58c1735a7eb58f8579f13a0de9e804 Mon Sep 17 00:00:00 2001 From: Mathieu Carbou Date: Mon, 12 Oct 2026 12:15:57 +0200 Subject: [PATCH 1/2] feat: user-controlled handler allocation (non-owning registration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give users full control over where handler objects live in memory (internal SRAM, PSRAM, static RAM) without allocator hooks or operator new overrides. Changes: - HandlerEntry struct tracks ownership explicitly (unique_ptr + raw ptr) - addHandler(ptr, takeOwnership=true) — extended with ownership flag - addHandler(ref) — non-owning reference overload for stack/static/PSRAM - AsyncWebServer(port, catchAllPtr, takeOwnership=true) — custom catch-all at construction - AsyncWebServer(port, catchAllRef) — non-owning reference overload - setCatchAllHandler(ptr, takeOwnership=true) / setCatchAllHandler(ref) - nullptr support: AsyncWebServer(port, nullptr) disables catch-all entirely; unmatched requests get a bare 501 response - catchAllHandlerPtr() — safe accessor returning nullptr instead of crashing - catchAllHandler() deprecated in favor of catchAllHandlerPtr() - onNotFound/onFileUpload/onRequestBody/reset are no-ops when no catch-all - New CustomAllocation example demonstrating static RAM, PSRAM, and server-owned handler patterns - Updated docs: routing.md, configuration.md, concepts.md Fully backward compatible: all existing APIs work unchanged. Existing sketches require zero modifications. Motivation: ESP32 internal SRAM is scarce (lwIP, WiFi buffers). Long-lived handler objects can now be moved to PSRAM or static RAM, freeing internal SRAM for networking. See discussion in #468. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts.md | 4 + docs/configuration.md | 54 +++++ docs/routing.md | 116 ++++++++++ .../CustomAllocation/CustomAllocation.ino | 153 +++++++++++++ .../SkipServerMiddleware.ino | 8 +- platformio.ini | 1 + src/ESPAsyncWebServer.h | 216 +++++++++++++++++- src/WebServer.cpp | 82 +++++-- 8 files changed, 610 insertions(+), 24 deletions(-) create mode 100644 examples/arduino/CustomAllocation/CustomAllocation.ino diff --git a/docs/concepts.md b/docs/concepts.md index e92f7aae4..cc0eba4c2 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -75,6 +75,10 @@ flowchart TD - `Handlers` are evaluated in the order they are attached to the server. The `canHandle` is called only if the `Filter` that was set to the `Handler` return true. - The first `Handler` that can handle the request is selected, not further `Filter` and `canHandle` are called. +- A `Handler` registered with `addHandler(ptr)` is **owned by the server** (deleted on removal or server destruction). + A `Handler` registered with `addHandler(ref)` or `addHandler(ptr, false)` is **non-owning** — the caller manages + its lifetime. This lets you place handlers in static RAM, PSRAM, or any custom memory region. + See [Routing — Handler Ownership and Memory Placement](routing.md#handler-ownership-and-memory-placement). ### Responses and how do they work diff --git a/docs/configuration.md b/docs/configuration.md index 3eab09482..e048c4562 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,3 +38,57 @@ Recommended configurations: - You can not use yield or delay or any function that uses them inside the callbacks - The server is smart enough to know when to close the connection and free resources - You can not send more than one response to a single request + +## Memory placement: PSRAM and static allocation + +On ESP32 targets with PSRAM, internal SRAM is often the scarcest resource (lwIP segments, WiFi buffers, and other networking allocations all live there). Handler objects — `AsyncCallbackWebHandler`, `AsyncStaticWebHandler`, `AsyncWebSocket`, `AsyncEventSource`, `AsyncCallbackJsonWebHandler` — are long-lived: they are created once at setup and only read during request dispatch. Moving them out of internal SRAM can free significant memory. + +By default, handlers created by `server.on()` or `server.serveStatic()` are allocated on the internal heap and owned by the server. To control where handler objects live, register them as **non-owning**: + +```cpp +// Static RAM — no heap allocation at all +static AsyncCallbackWebHandler handler; +handler.setUri(AsyncURIMatcher::exact("/status")); +handler.onRequest([](AsyncWebServerRequest *request) { + request->send(200, "text/plain", "OK"); +}); +server.addHandler(handler); // non-owning reference overload +``` + +```cpp +// PSRAM — placement new with heap_caps_malloc (ESP32) +#include + +void *mem = heap_caps_malloc(sizeof(AsyncCallbackWebHandler), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +auto *handler = new (mem) AsyncCallbackWebHandler(); +handler->setUri(AsyncURIMatcher::exact("/status")); +handler->onRequest([](AsyncWebServerRequest *request) { + request->send(200, "text/plain", "OK"); +}); +server.addHandler(handler, false); // non-owning — manage lifetime yourself +``` + +The same applies to the catch-all (not-found) handler — either at construction time or afterwards: + +```cpp +// At construction — non-owning (reference), lives in static RAM +static AsyncCallbackWebHandler myCatchAll; +AsyncWebServer server(80, myCatchAll); + +// At construction — non-owning (pointer), e.g. PSRAM +AsyncWebServer server(80, psramCatchAll, false); + +// At construction — no catch-all at all (unmatched → bare 501) +AsyncWebServer server(80, nullptr); + +// After construction +server.setCatchAllHandler(myCatchAllHandler); // non-owning (reference) +server.setCatchAllHandler(psramCatchAll, false); // non-owning (pointer) +server.setCatchAllHandler(new AsyncCallbackWebHandler()); // server-owned (default) +server.setCatchAllHandler(nullptr); // remove catch-all entirely +``` + +> [!NOTE] +> When using non-owning registration, you are responsible for keeping the handler alive while it is registered with the server, and for destroying it after unregistration. For PSRAM handlers, remember to call the destructor explicitly and then `heap_caps_free()` the memory block. + +See the [CustomAllocation example](https://github.com/ESP32Async/ESPAsyncWebServer/blob/master/examples/arduino/CustomAllocation/CustomAllocation.ino) and the [Routing](routing.md#handler-ownership-and-memory-placement) documentation for more details. diff --git a/docs/routing.md b/docs/routing.md index e254916f7..ef7148b48 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -123,6 +123,122 @@ Usage: See the [Rewrite example here](https://github.com/ESP32Async/ESPAsyncWebServer/blob/master/examples/arduino/Rewrite/Rewrite.ino). +## Handler Ownership and Memory Placement + +By default, handlers created by `server.on()`, `server.serveStatic()`, or passed to `server.addHandler(new MyHandler())` are **owned by the server** — it deletes them on `removeHandler()`, `reset()`, or destruction. + +When you need to control **where** a handler object lives in memory (e.g. to use PSRAM on ESP32, or to avoid heap allocation entirely), register it as **non-owning**: + +| Registration | Ownership | Use case | +|---|---|---| +| `server.addHandler(new MyHandler())` | Server owns | Default, backward compatible | +| `server.addHandler(ptr, false)` | Non-owning (pointer) | Heap or PSRAM via placement new | +| `server.addHandler(myHandler)` | Non-owning (reference) | Stack, static, or global object | + +With non-owning registration, **you** are responsible for keeping the handler alive while it is registered, and for destroying it after unregistration. + +### Static / Global RAM (no heap allocation) + +```cpp +static AsyncWebServer server(80); + +// Lives in .bss — zero heap usage +static AsyncCallbackWebHandler statusHandler; + +void setup() { + statusHandler.setUri(AsyncURIMatcher::exact("/status")); + statusHandler.onRequest([](AsyncWebServerRequest *request) { + request->send(200, "application/json", "{\"ok\":true}"); + }); + server.addHandler(statusHandler); // non-owning reference overload + server.begin(); +} +``` + +### PSRAM (ESP32) + +```cpp +#include + +void setup() { + void *mem = heap_caps_malloc(sizeof(AsyncCallbackWebHandler), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + auto *handler = new (mem) AsyncCallbackWebHandler(); + handler->setUri(AsyncURIMatcher::exact("/status")); + handler->onRequest([](AsyncWebServerRequest *request) { + request->send(200, "application/json", "{\"ok\":true}"); + }); + server.addHandler(handler, false); // non-owning — manage PSRAM lifetime yourself + server.begin(); +} +``` + +### WebSocket / EventSource / Static File Handlers + +The same pattern works for any `AsyncWebHandler` subclass — `AsyncWebSocket`, `AsyncEventSource`, `AsyncStaticWebHandler`, `AsyncCallbackJsonWebHandler`: + +```cpp +static AsyncWebSocket ws("/ws"); +ws.onEvent([](AsyncWebSocket *s, AsyncWebSocketClient *c, AwsEventType t, void *a, uint8_t *d, size_t l) { + if (t == WS_EVT_DATA) s->text(c->id(), "echo"); +}); +server.addHandler(ws); // non-owning — ws lives in static RAM + +static AsyncStaticWebHandler staticHandler("/static", LittleFS, "/www"); +server.addHandler(staticHandler); // non-owning +``` + +### Custom Catch-All Handler + +The catch-all (not-found) handler can be customized in two ways: + +**At construction time** — pass your handler as a second argument to the `AsyncWebServer` constructor: + +| Constructor | Ownership | +|---|---| +| `AsyncWebServer server(80)` | Server creates & owns a default catch-all | +| `AsyncWebServer server(80, new AsyncCallbackWebHandler())` | Server owns | +| `AsyncWebServer server(80, ptr, false)` | Non-owning (pointer) | +| `AsyncWebServer server(80, myHandler)` | Non-owning (reference) | +| `AsyncWebServer server(80, nullptr)` | **No catch-all** — unmatched requests get a bare 501 | + +```cpp +static AsyncCallbackWebHandler catchAll; + +void setup() { + catchAll.onRequest([](AsyncWebServerRequest *request) { + request->send(404, "text/plain", "Not found: " + request->url()); + }); + // Pass custom catch-all at construction — non-owning, lives in static RAM + AsyncWebServer server(80, catchAll); + server.begin(); +} +``` + +**After construction** — replace it with `setCatchAllHandler()`: + +| Registration | Ownership | +|---|---| +| `server.setCatchAllHandler(new AsyncCallbackWebHandler())` | Server owns (default) | +| `server.setCatchAllHandler(ptr, false)` | Non-owning (pointer) | +| `server.setCatchAllHandler(myHandler)` | Non-owning (reference) | +| `server.setCatchAllHandler(nullptr)` | **Remove catch-all** — unmatched requests get a bare 501 | + +```cpp +server.setCatchAllHandler(catchAll); // non-owning — catchAll lives in static RAM +``` + +> [!NOTE] +> When no catch-all handler is installed (`nullptr`), `onNotFound()`, `onFileUpload()`, `onRequestBody()`, and `reset()` are no-ops. Use `catchAllHandlerPtr()` to safely check for the catch-all handler — it returns `nullptr` instead of crashing. Unmatched requests receive a bare `501 Not Implemented` response with the message "Handler did not handle the request". +> +> The older `catchAllHandler()` method is deprecated — it crashes if no catch-all is installed. Replace it with: +> ```cpp +> if (auto *catchAll = server.catchAllHandlerPtr()) { +> catchAll->addMiddleware(myMiddleware); +> } +> ``` + +See the [CustomAllocation example](https://github.com/ESP32Async/ESPAsyncWebServer/blob/master/examples/arduino/CustomAllocation/CustomAllocation.ino) for a complete sketch. + ## Remove handlers and rewrites Server goes through handlers in same order as they were added. You can't simple add handler with same path to override them. diff --git a/examples/arduino/CustomAllocation/CustomAllocation.ino b/examples/arduino/CustomAllocation/CustomAllocation.ino new file mode 100644 index 000000000..ba45dfeb4 --- /dev/null +++ b/examples/arduino/CustomAllocation/CustomAllocation.ino @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright 2016-2026 Hristo Gochkov, Mathieu Carbou, Emil Muratov, Will Miles + +// +// Shows how to control where handler objects are allocated. +// +// The server offers two registration styles: +// - addHandler(ptr) — server owns the handler (default, backward compatible) +// - addHandler(ref) — non-owning, the user manages the handler's lifetime +// - addHandler(ptr, false) — non-owning via pointer +// +// With non-owning registration you can place handlers in: +// - Static / global RAM (no heap allocation at all) +// - PSRAM (ESP32: placement new with heap_caps_malloc) +// +// The same applies to the catch-all handler — via the constructor or setCatchAllHandler(). +// You can also pass nullptr to disable the catch-all entirely. +// + +#include +#if defined(ESP32) || defined(LIBRETINY) +#include +#include +#if defined(ESP32) +#include +#endif +#elif defined(ESP8266) +#include +#include +#elif defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350) +#include +#include +#endif + +#include +#include + +// --------------------------------------------------------------------------- +// Custom catch-all handler — static RAM, non-owning. +// Passed to the server constructor so it's used from the very first request. +// --------------------------------------------------------------------------- +static AsyncCallbackWebHandler catchAllHandler; + +static AsyncWebServer server(80, catchAllHandler); // non-owning reference + +// --------------------------------------------------------------------------- +// 1. Static RAM handler — allocated in .bss, zero heap usage +// --------------------------------------------------------------------------- +static AsyncCallbackWebHandler statusHandler; + +// --------------------------------------------------------------------------- +// 2. PSRAM handler (ESP32 only) — allocated via placement new in PSRAM +// --------------------------------------------------------------------------- +#if defined(ESP32) +static AsyncCallbackWebHandler *apiHandler = nullptr; +static void *apiHandlerMem = nullptr; +#endif + +// --------------------------------------------------------------------------- +// 3. Server-owned handler — classic style, server manages lifetime +// --------------------------------------------------------------------------- +// (see server.on() below) + +void setup() { + Serial.begin(115200); + while (!Serial) + ; + +#if ASYNCWEBSERVER_WIFI_SUPPORTED + WiFi.mode(WIFI_AP); + WiFi.softAP("esp-custom-alloc"); +#endif + + if (!LittleFS.begin()) { + Serial.println("LittleFS mount failed"); + } + + // --- 1. Static RAM handler ------------------------------------------------ + // No heap allocation — the object lives in .bss for the entire program. + statusHandler.setUri(AsyncURIMatcher::exact("/status")); + statusHandler.setMethod(HTTP_GET); + statusHandler.onRequest([](AsyncWebServerRequest *request) { +#if defined(ESP32) + String json = "{\"uptime\":" + String(millis()) + ",\"free_heap\":" + String(ESP.getFreeHeap()) + "}"; +#else + String json = "{\"uptime\":" + String(millis()) + "}"; +#endif + request->send(200, "application/json", json); + }); + server.addHandler(statusHandler); // non-owning reference overload + + // --- 2. PSRAM handler (ESP32 only) ---------------------------------------- +#if defined(ESP32) + if (psramFound()) { + apiHandlerMem = heap_caps_malloc(sizeof(AsyncCallbackWebHandler), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (apiHandlerMem) { + apiHandler = new (apiHandlerMem) AsyncCallbackWebHandler(); + apiHandler->setUri(AsyncURIMatcher::prefix("/api")); + apiHandler->setMethod(HTTP_GET); + apiHandler->onRequest([](AsyncWebServerRequest *request) { + request->send(200, "application/json", "{\"ok\":true,\"region\":\"psram\"}"); + }); + // Non-owning: the server stores a reference; we manage PSRAM lifetime. + server.addHandler(apiHandler, false); + Serial.printf("API handler allocated in PSRAM at %p (%u bytes free in PSRAM)\n", + apiHandlerMem, ESP.getFreePsram()); + } else { + Serial.println("PSRAM allocation failed for API handler"); + } + } else { + Serial.println("No PSRAM found — skipping PSRAM handler demo"); + } +#endif + + // --- 3. Server-owned handler (classic convenience API) -------------------- + server.on("/hello", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send(200, "text/plain", "Hello from a server-owned handler!"); + }); + + // --- Static file serving (server-owned) ----------------------------------- + server.serveStatic("/static", LittleFS, "/www"); + + // --- Configure the custom catch-all (passed to constructor above) ---------- + catchAllHandler.onRequest([](AsyncWebServerRequest *request) { + String msg = "404 - Not found: " + request->url(); + request->send(404, "text/plain", msg); + }); + + // --- Alternative: no catch-all at all -------------------------------------- + // Uncomment to disable the catch-all and get a bare 501 for unmatched requests: + // AsyncWebServer server(80, nullptr); + // or after construction: + // server.setCatchAllHandler(nullptr); + + server.begin(); + Serial.println("HTTP server started"); +#if ASYNCWEBSERVER_WIFI_SUPPORTED + Serial.printf("Connect to AP 'esp-custom-alloc' and open http://%s\n", + WiFi.softAPIP().toString().c_str()); +#endif + Serial.println("Try:"); + Serial.println(" http://192.168.4.1/status (static RAM handler)"); +#if defined(ESP32) + Serial.println(" http://192.168.4.1/api/test (PSRAM handler, ESP32 only)"); +#endif + Serial.println(" http://192.168.4.1/hello (server-owned handler)"); + Serial.println(" http://192.168.4.1/anything (custom catch-all)"); + Serial.println(" http://192.168.4.1/static/ (static file serving)"); +} + +void loop() { + // Nothing to do — the async server handles everything. +} diff --git a/examples/arduino/SkipServerMiddleware/SkipServerMiddleware.ino b/examples/arduino/SkipServerMiddleware/SkipServerMiddleware.ino index bee75617f..f8d1584d0 100644 --- a/examples/arduino/SkipServerMiddleware/SkipServerMiddleware.ino +++ b/examples/arduino/SkipServerMiddleware/SkipServerMiddleware.ino @@ -54,9 +54,11 @@ void setup() { }); // we skip all global middleware from the catchall handler - server.catchAllHandler().skipServerMiddlewares(); - // we apply a specific middleware to the catchall handler only to log requests without a handler defined - server.catchAllHandler().addMiddleware(&logging); + if (auto *catchAll = server.catchAllHandlerPtr()) { + catchAll->skipServerMiddlewares(); + // we apply a specific middleware to the catchall handler only to log requests without a handler defined + catchAll->addMiddleware(&logging); + } // standard 404 handler: will display the request in the console i na curl-like style // curl -v -H "Foo: Bar" http://192.168.4.1/foo diff --git a/platformio.ini b/platformio.ini index 37e0a3e29..4e48443bc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -10,6 +10,7 @@ lib_dir = . ; src_dir = examples/arduino/ChunkResponse ; src_dir = examples/arduino/ChunkRetryResponse ; src_dir = examples/arduino/CORS +; src_dir = examples/arduino/CustomAllocation ; src_dir = examples/arduino/EndBegin ; src_dir = examples/arduino/Filters ; src_dir = examples/arduino/FlashResponse diff --git a/src/ESPAsyncWebServer.h b/src/ESPAsyncWebServer.h index 7fcadeb5a..1b81e85fe 100644 --- a/src/ESPAsyncWebServer.h +++ b/src/ESPAsyncWebServer.h @@ -1681,13 +1681,80 @@ typedef AsyncCallbackJsonWebHandler AsyncCallbackMessagePackWebHandler; class AsyncWebServer : public AsyncMiddlewareChain { protected: + // Handler entry: tracks ownership explicitly. + // If `owned` is non-null, the server owns the handler and will delete it. + // If `owned` is null, the handler is user-managed (non-owning registration). + struct HandlerEntry { + AsyncWebHandler *handler; // always valid, used for dispatch + std::unique_ptr owned; // non-null if server owns it + }; + AsyncServer _server; std::list> _rewrites; - std::list> _handlers; - AsyncCallbackWebHandler *_catchAllHandler; + std::list _handlers; + // May be nullptr if the user explicitly opted out of a catch-all handler. + // When nullptr, unmatched requests get a bare 501 response (no handler chain runs). + AsyncCallbackWebHandler *_catchAllHandler = nullptr; + bool _catchAllHandlerOwned = false; public: + /** + * @brief Construct a server with a default internal catch-all handler. + * + * Delegates to AsyncWebServer(port, new AsyncCallbackWebHandler(), true). + * The server creates and owns an `AsyncCallbackWebHandler` used when no + * registered handler matches a request. + */ AsyncWebServer(uint16_t port); + + /** + * @brief Construct a server with a user-provided catch-all handler. + * + * The server takes ownership by default and will delete the handler on + * destruction. Pass `takeOwnership = false` for a non-owning registration + * (the caller manages the handler's lifetime). This is useful to place the + * catch-all in PSRAM or static RAM. + * + * Pass `nullptr` to opt out of a catch-all handler entirely. Unmatched + * requests will then receive a bare 501 response. Note that onNotFound(), + * onFileUpload(), onRequestBody() and reset() are no-ops when no catch-all + * is installed. Use catchAllHandlerPtr() to safely access the catch-all. + * + * Example (PSRAM on ESP32): + * @code + * void *mem = heap_caps_malloc(sizeof(AsyncCallbackWebHandler), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + * auto *catchAll = new (mem) AsyncCallbackWebHandler(); + * AsyncWebServer server(80, catchAll, false); // non-owning + * @endcode + * + * Example (no catch-all at all): + * @code + * AsyncWebServer server(80, nullptr); // unmatched requests → bare 501 + * @endcode + * + * @param port TCP port to listen on. + * @param catchAllHandler Pointer to the catch-all handler, or nullptr for none. + * @param takeOwnership If true (default), the server owns and will delete the handler. + */ + AsyncWebServer(uint16_t port, AsyncCallbackWebHandler *catchAllHandler, bool takeOwnership = true); + + /** + * @brief Construct a server with a user-provided catch-all handler (non-owning). + * + * The server stores a non-owning reference. The caller is responsible for + * keeping the handler alive and for destroying it after the server is gone. + * + * Example (static RAM): + * @code + * AsyncCallbackWebHandler catchAll; + * AsyncWebServer server(80, catchAll); // non-owning + * @endcode + * + * @param port TCP port to listen on. + * @param catchAllHandler Reference to the catch-all handler. Must remain valid. + */ + AsyncWebServer(uint16_t port, AsyncCallbackWebHandler &catchAllHandler); + ~AsyncWebServer(); void begin(); @@ -1748,9 +1815,117 @@ class AsyncWebServer : public AsyncMiddlewareChain { */ bool removeRewrite(const char *from, const char *to); - AsyncWebHandler &addHandler(AsyncWebHandler *handler); + /** + * @brief Register a handler for the server. + * + * The server takes ownership by default and will delete the handler on + * removeHandler(), reset(), or destruction. Pass `takeOwnership = false` + * for a non-owning registration (the caller manages the handler's lifetime). + * + * Example (owning, heap-allocated — default, backward compatible): + * @code + * server.addHandler(new MyHandler()); + * @endcode + * + * Example (non-owning, user-managed — e.g. PSRAM via placement new on ESP32): + * @code + * void *mem = heap_caps_malloc(sizeof(AsyncCallbackWebHandler), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + * auto *h = new (mem) AsyncCallbackWebHandler(); + * h->setUri(AsyncURIMatcher::exact("/status")); + * h->onRequest([](AsyncWebServerRequest *req) { req->send(200, "text/plain", "OK"); }); + * server.addHandler(h, false); // non-owning — user manages lifetime + * @endcode + * + * @param handler Pointer to the handler to register. Must remain valid while registered. + * @param takeOwnership If true (default), the server owns and will delete the handler. + * If false, the server stores a non-owning reference. + * @return Reference to the registered handler. + */ + AsyncWebHandler &addHandler(AsyncWebHandler *handler, bool takeOwnership = true); + + /** + * @brief Register a user-managed handler by reference (non-owning). + * + * The server stores a non-owning reference. The caller is responsible for + * keeping the handler alive while it is registered, and for destroying it + * after unregistration (or at program end). + * + * This overload is the natural choice for stack, static, or PSRAM-allocated + * handlers — it makes the non-owning intent explicit at the call site. + * + * Example (static RAM — no heap allocation): + * @code + * AsyncCallbackWebHandler handler; + * handler.setUri(AsyncURIMatcher::exact("/status")); + * handler.onRequest([](AsyncWebServerRequest *req) { req->send(200, "text/plain", "OK"); }); + * server.addHandler(handler); + * @endcode + * + * @param handler Reference to the handler to register. + * @return Reference to the registered handler (same object). + */ + AsyncWebHandler &addHandler(AsyncWebHandler &handler); + bool removeHandler(AsyncWebHandler *handler); + /** + * @brief Replace the catch-all (not-found) handler with a user-provided one. + * + * The server takes ownership by default and will delete the handler on + * reset() or destruction. Pass `takeOwnership = false` for a non-owning + * replacement (the caller manages the handler's lifetime). + * + * Pass `nullptr` to remove the catch-all handler entirely. Unmatched + * requests will then receive a bare 501 response. + * + * If the previously installed catch-all was server-owned, it is deleted + * when replaced. + * + * Example (owning, heap-allocated — default): + * @code + * server.setCatchAllHandler(new AsyncCallbackWebHandler()); + * @endcode + * + * Example (non-owning, PSRAM on ESP32): + * @code + * void *mem = heap_caps_malloc(sizeof(AsyncCallbackWebHandler), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + * auto *catchAll = new (mem) AsyncCallbackWebHandler(); + * catchAll->onRequest([](AsyncWebServerRequest *req) { req->send(404, "text/plain", "Not found"); }); + * server.setCatchAllHandler(catchAll, false); // non-owning + * @endcode + * + * Example (remove catch-all entirely): + * @code + * server.setCatchAllHandler(nullptr); // unmatched requests → bare 501 + * @endcode + * + * @param handler Pointer to the replacement catch-all handler, or nullptr for none. + * @param takeOwnership If true (default), the server owns and will delete the handler. + * If false, the server stores a non-owning reference. + */ + void setCatchAllHandler(AsyncCallbackWebHandler *handler, bool takeOwnership = true); + + /** + * @brief Replace the catch-all (not-found) handler with a user-provided one (non-owning). + * + * The server stores a non-owning reference. The caller is responsible for + * keeping the handler alive and for destroying it after replacement or at + * program end. + * + * This overload is the natural choice for stack, static, or PSRAM-allocated + * catch-all handlers — it makes the non-owning intent explicit at the call site. + * + * Example (static RAM): + * @code + * AsyncCallbackWebHandler catchAll; + * catchAll.onRequest([](AsyncWebServerRequest *req) { req->send(404, "text/plain", "Not found"); }); + * server.setCatchAllHandler(catchAll); + * @endcode + * + * @param handler Reference to the replacement catch-all handler. Must remain valid. + */ + void setCatchAllHandler(AsyncCallbackWebHandler &handler); + AsyncCallbackWebHandler &on(AsyncURIMatcher uri, ArRequestHandlerFunction onRequest) { return on(std::move(uri), AsyncWebRequestMethod::HTTP_ALL, onRequest); } @@ -1765,10 +1940,43 @@ class AsyncWebServer : public AsyncMiddlewareChain { AsyncStaticWebHandler &serveStatic(const char *uri, fs::FS &fs, const char *path, const char *cache_control = NULL); + // All of the following are no-ops when no catch-all handler is installed (_catchAllHandler == nullptr). void onNotFound(ArRequestHandlerFunction fn); // called when handler is not assigned void onFileUpload(ArUploadHandlerFunction fn); // handle file uploads void onRequestBody(ArBodyHandlerFunction fn); // handle posts with plain body content (JSON often transmitted this way as a request) - // give access to the handler used to catch all requests, so that middleware can be added to it + + /** + * @brief Get a pointer to the catch-all handler, or nullptr if none is installed. + * + * Safe to call at any time. Returns nullptr when the server was constructed + * with `nullptr` or when setCatchAllHandler(nullptr) was called. + * Does not transfer ownership. + * + * Example: + * @code + * if (auto *catchAll = server.catchAllHandlerPtr()) { + * catchAll->addMiddleware(myMiddleware); + * } + * @endcode + * + * @return Pointer to the catch-all handler, or nullptr if none is installed. + */ + AsyncCallbackWebHandler *catchAllHandlerPtr() const { + return _catchAllHandler; + } + + /** + * @brief Get a reference to the catch-all handler. + * + * @deprecated Use catchAllHandlerPtr() instead — it is safe to call even when + * no catch-all handler is installed (returns nullptr). + * This method crashes (undefined behavior) if no catch-all handler + * is installed. + * + * Give access to the handler used to catch all requests, so that middleware + * can be added to it. + */ + [[deprecated("Use catchAllHandlerPtr() instead — it returns nullptr instead of crashing when no catch-all is installed.")]] AsyncWebHandler &catchAllHandler() const; void reset(); // remove all writers and handlers, with onNotFound/onFileUpload/onRequestBody diff --git a/src/WebServer.cpp b/src/WebServer.cpp index a8ef376b4..3ccdd5b53 100644 --- a/src/WebServer.cpp +++ b/src/WebServer.cpp @@ -40,8 +40,11 @@ const char *fs::FileOpenMode::write = "w"; const char *fs::FileOpenMode::append = "a"; #endif -AsyncWebServer::AsyncWebServer(uint16_t port) : _server(port) { - _catchAllHandler = new AsyncCallbackWebHandler(); +AsyncWebServer::AsyncWebServer(uint16_t port) : AsyncWebServer(port, new AsyncCallbackWebHandler(), true) {} + +AsyncWebServer::AsyncWebServer(uint16_t port, AsyncCallbackWebHandler *catchAllHandler, bool takeOwnership) : _server(port) { + _catchAllHandler = catchAllHandler; + _catchAllHandlerOwned = takeOwnership && catchAllHandler != nullptr; _server.onClient( [](void *s, AsyncClient *c) { if (c == NULL) { @@ -58,10 +61,14 @@ AsyncWebServer::AsyncWebServer(uint16_t port) : _server(port) { ); } +AsyncWebServer::AsyncWebServer(uint16_t port, AsyncCallbackWebHandler &catchAllHandler) : AsyncWebServer(port, &catchAllHandler, false) {} + AsyncWebServer::~AsyncWebServer() { reset(); end(); - delete _catchAllHandler; + if (_catchAllHandlerOwned && _catchAllHandler) { + delete _catchAllHandler; + } _catchAllHandler = nullptr; // Prevent potential use-after-free } @@ -94,14 +101,28 @@ AsyncWebRewrite &AsyncWebServer::rewrite(const char *from, const char *to) { return *_rewrites.back().get(); } -AsyncWebHandler &AsyncWebServer::addHandler(AsyncWebHandler *handler) { - _handlers.emplace_back(handler); - return *(_handlers.back().get()); +AsyncWebHandler &AsyncWebServer::addHandler(AsyncWebHandler *handler, bool takeOwnership) { + HandlerEntry entry; + entry.handler = handler; + if (takeOwnership) { + entry.owned.reset(handler); + } + _handlers.push_back(std::move(entry)); + return *handler; +} + +AsyncWebHandler &AsyncWebServer::addHandler(AsyncWebHandler &handler) { + HandlerEntry entry; + entry.handler = &handler; + // entry.owned stays null — non-owning registration + _handlers.push_back(std::move(entry)); + return handler; } bool AsyncWebServer::removeHandler(AsyncWebHandler *handler) { for (auto i = _handlers.begin(); i != _handlers.end(); ++i) { - if (i->get() == handler) { + if (i->handler == handler) { + // unique_ptr in entry.owned will delete if owned; non-owned entries are just removed _handlers.erase(i); return true; } @@ -140,13 +161,14 @@ void AsyncWebServer::_rewriteRequest(AsyncWebServerRequest *request) { } void AsyncWebServer::_attachHandler(AsyncWebServerRequest *request) { - for (auto &h : _handlers) { - if (h->filter(request) && h->canHandle(request)) { - request->setHandler(h.get()); + for (auto &entry : _handlers) { + if (entry.handler->filter(request) && entry.handler->canHandle(request)) { + request->setHandler(entry.handler); return; } } - // ESP_LOGD("AsyncWebServer", "No handler found for %s, using _catchAllHandler pointer: %p", request->url().c_str(), _catchAllHandler); + // If no handler matched and no catch-all is installed, setHandler(nullptr) leaves + // _handler null — _runMiddlewareChain() skips the handler chain and _send() replies 501. request->setHandler(_catchAllHandler); } @@ -179,28 +201,54 @@ AsyncStaticWebHandler &AsyncWebServer::serveStatic(const char *uri, fs::FS &fs, } void AsyncWebServer::onNotFound(ArRequestHandlerFunction fn) { - _catchAllHandler->onRequest(fn); + if (_catchAllHandler) { + _catchAllHandler->onRequest(fn); + } } void AsyncWebServer::onFileUpload(ArUploadHandlerFunction fn) { - _catchAllHandler->onUpload(fn); + if (_catchAllHandler) { + _catchAllHandler->onUpload(fn); + } } void AsyncWebServer::onRequestBody(ArBodyHandlerFunction fn) { - _catchAllHandler->onBody(fn); + if (_catchAllHandler) { + _catchAllHandler->onBody(fn); + } } AsyncWebHandler &AsyncWebServer::catchAllHandler() const { + // Crashes if no catch-all handler is installed (_catchAllHandler == nullptr). + // Use setCatchAllHandler() to install one, or check onNotFound() behavior beforehand. return *_catchAllHandler; } +void AsyncWebServer::setCatchAllHandler(AsyncCallbackWebHandler *handler, bool takeOwnership) { + if (_catchAllHandlerOwned && _catchAllHandler && _catchAllHandler != handler) { + delete _catchAllHandler; + } + _catchAllHandler = handler; + _catchAllHandlerOwned = takeOwnership && handler != nullptr; +} + +void AsyncWebServer::setCatchAllHandler(AsyncCallbackWebHandler &handler) { + if (_catchAllHandlerOwned && _catchAllHandler && _catchAllHandler != &handler) { + delete _catchAllHandler; + } + _catchAllHandler = &handler; + _catchAllHandlerOwned = false; +} + void AsyncWebServer::reset() { _rewrites.clear(); _handlers.clear(); - _catchAllHandler->onRequest(NULL); - _catchAllHandler->onUpload(NULL); - _catchAllHandler->onBody(NULL); + if (_catchAllHandler) { + _catchAllHandler->onRequest(NULL); + _catchAllHandler->onUpload(NULL); + _catchAllHandler->onBody(NULL); + } } AsyncURIMatcher::AsyncURIMatcher(String uri, uint16_t modifiers) : _value(std::move(uri)) { From 2230ba6c28452fb95ce0689f2f7257343ac378ac Mon Sep 17 00:00:00 2001 From: Mathieu Carbou Date: Mon, 12 Oct 2026 14:10:27 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20route=20tab?= =?UTF-8?q?le,=20one=20handler=20serving=20multiple=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple URI/method matching from handler objects by introducing a server-side routing table (RouteEntry). This enables one handler object to serve multiple routes, combining Phase 1's ownership control with Phase 2's routing flexibility. Changes: - RouteEntry struct: { AsyncURIMatcher uri, WebRequestMethodComposite method, bool hasMatcher, HandlerEntry entry } - addRoute(uri, method, handlerPtr, takeOwnership=true) — owning registration - addRoute(uri, method, handlerRef) — non-owning registration - removeRoute(uri, method) — remove a specific route - removeHandler() now also removes routes pointing to that handler - _attachHandler() evaluates routes first (URI+method match), then legacy handlers (canHandle-based dispatch) — backward compatible - AsyncCallbackWebHandler::_routeManaged flag — when set, canHandle() skips URI/method checks (server's route table does them) - AsyncURIMatcher::operator==/!= for route removal by value comparison - reset() clears both _routes and _handlers - New MultiRoute example: one handler on 3 routes (static RAM), PSRAM handler on 2 routes, classic on() handler, custom catch-all - Updated docs: routing.md (Route Table section), configuration.md, concepts.md Backward compatible: all Phase 1 APIs and existing on()/addHandler()/ serveStatic() calls work identically. Routes are additive. Builds on #493 (Phase 1: non-owning handler registration). Motivation from #468 discussion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts.md | 3 + docs/configuration.md | 20 +++ docs/routing.md | 84 +++++++++++++ examples/arduino/MultiRoute/MultiRoute.ino | 138 +++++++++++++++++++++ platformio.ini | 1 + src/ESPAsyncWebServer.h | 77 +++++++++++- src/WebHandlerImpl.h | 13 ++ src/WebHandlers.cpp | 11 +- src/WebServer.cpp | 68 ++++++++++ 9 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 examples/arduino/MultiRoute/MultiRoute.ino diff --git a/docs/concepts.md b/docs/concepts.md index cc0eba4c2..b164f125c 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -79,6 +79,9 @@ flowchart TD A `Handler` registered with `addHandler(ref)` or `addHandler(ptr, false)` is **non-owning** — the caller manages its lifetime. This lets you place handlers in static RAM, PSRAM, or any custom memory region. See [Routing — Handler Ownership and Memory Placement](routing.md#handler-ownership-and-memory-placement). +- The server maintains a **route table** (`addRoute()`) that decouples URI/method matching from the handler object. + Routes are evaluated before legacy handlers. One handler object can serve multiple routes. + See [Routing — Route Table](routing.md#route-table-one-handler-multiple-routes). ### Responses and how do they work diff --git a/docs/configuration.md b/docs/configuration.md index e048c4562..240aa62af 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -92,3 +92,23 @@ server.setCatchAllHandler(nullptr); // remove catch-all entirel > When using non-owning registration, you are responsible for keeping the handler alive while it is registered with the server, and for destroying it after unregistration. For PSRAM handlers, remember to call the destructor explicitly and then `heap_caps_free()` the memory block. See the [CustomAllocation example](https://github.com/ESP32Async/ESPAsyncWebServer/blob/master/examples/arduino/CustomAllocation/CustomAllocation.ino) and the [Routing](routing.md#handler-ownership-and-memory-placement) documentation for more details. + +## Route Table: One Handler, Multiple Routes + +The `addRoute()` API decouples URI matching from the handler object — the server's routing table stores the URI matcher and HTTP method alongside a handler pointer. This lets **one handler object serve multiple routes**: + +```cpp +static AsyncCallbackWebHandler handler; +handler.onRequest([](AsyncWebServerRequest *request) { + request->send(200, "text/plain", "OK"); +}); + +// Same handler, three routes (non-owning, static RAM): +server.addRoute(AsyncURIMatcher::exact("/foo"), HTTP_GET, handler); +server.addRoute(AsyncURIMatcher::exact("/bar"), HTTP_GET, handler); +server.addRoute(AsyncURIMatcher::exact("/status"), HTTP_GET, handler, false); // non-owning ptr +``` + +Routes are evaluated before legacy handlers (`on()`, `addHandler()`, `serveStatic()`). Existing code works identically — no migration required. + +See the [MultiRoute example](https://github.com/ESP32Async/ESPAsyncWebServer/blob/master/examples/arduino/MultiRoute/MultiRoute.ino) and the [Routing](routing.md#route-table-one-handler-multiple-routes) documentation for more details. diff --git a/docs/routing.md b/docs/routing.md index ef7148b48..11668d479 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -239,6 +239,90 @@ server.setCatchAllHandler(catchAll); // non-owning — catchAll lives in static See the [CustomAllocation example](https://github.com/ESP32Async/ESPAsyncWebServer/blob/master/examples/arduino/CustomAllocation/CustomAllocation.ino) for a complete sketch. +## Route Table: One Handler, Multiple Routes + +The `addRoute()` API decouples **URI matching** from the **handler object**. The server's routing table stores `{ URI matcher, HTTP method, handler pointer }`, so a single handler object can serve multiple routes. + +Routes are evaluated in insertion order, **before** legacy handlers (registered via `addHandler()` or `on()`). + +| Registration | Ownership | Use case | +|---|---|---| +| `server.addRoute(uri, method, new MyHandler())` | Server owns | Default, backward compatible | +| `server.addRoute(uri, method, ptr, false)` | Non-owning (pointer) | Heap or PSRAM via placement new | +| `server.addRoute(uri, method, myHandler)` | Non-owning (reference) | Stack, static, or global object | + +### One Handler, Multiple Routes + +```cpp +static AsyncWebServer server(80); + +// One handler object — lives in static RAM +static AsyncCallbackWebHandler greetHandler; + +void setup() { + greetHandler.onRequest([](AsyncWebServerRequest *request) { + request->send(200, "text/plain", "Hello from " + request->url()); + }); + + // Same handler on three different routes: + server.addRoute(AsyncURIMatcher::exact("/hello"), HTTP_GET, greetHandler); + server.addRoute(AsyncURIMatcher::exact("/hi"), HTTP_GET, greetHandler); + server.addRoute(AsyncURIMatcher::prefix("/greet/"), HTTP_GET, greetHandler); + + server.begin(); +} +``` + +### Method-Based Routing + +The route table also filters by HTTP method — a `GET` route won't match a `POST` request: + +```cpp +static AsyncCallbackWebHandler handler; +handler.onRequest([](AsyncWebServerRequest *request) { + request->send(200, "text/plain", "GET response"); +}); +handler.onBody([](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {}); + +// Only GET /data matches this route; POST /data falls through to the next handler +server.addRoute(AsyncURIMatcher::exact("/data"), HTTP_GET, handler); +``` + +### PSRAM Handler on Multiple Routes (ESP32) + +```cpp +#include + +void setup() { + void *mem = heap_caps_malloc(sizeof(AsyncCallbackWebHandler), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + auto *handler = new (mem) AsyncCallbackWebHandler(); + handler->onRequest([](AsyncWebServerRequest *request) { + request->send(200, "application/json", "{\"ok\":true}"); + }); + + // Same PSRAM handler on two routes — non-owning: + server.addRoute(AsyncURIMatcher::prefix("/api/"), HTTP_GET, handler, false); + server.addRoute(AsyncURIMatcher::exact("/status"), HTTP_GET, handler, false); + + server.begin(); +} +``` + +### Removing Routes + +```cpp +// Remove a specific route by URI + method: +server.removeRoute(AsyncURIMatcher::exact("/hello"), HTTP_GET); + +// removeHandler() also works for route-registered handlers: +server.removeHandler(&greetHandler); // removes ALL routes pointing to this handler +``` + +> [!NOTE] +> Routes and legacy handlers (`on()`, `addHandler()`, `serveStatic()`) coexist. Routes are checked first. Existing code using `on()` / `addHandler()` works identically — no migration required. + +See the [MultiRoute example](https://github.com/ESP32Async/ESPAsyncWebServer/blob/master/examples/arduino/MultiRoute/MultiRoute.ino) for a complete sketch. + ## Remove handlers and rewrites Server goes through handlers in same order as they were added. You can't simple add handler with same path to override them. diff --git a/examples/arduino/MultiRoute/MultiRoute.ino b/examples/arduino/MultiRoute/MultiRoute.ino new file mode 100644 index 000000000..c7c51d64f --- /dev/null +++ b/examples/arduino/MultiRoute/MultiRoute.ino @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright 2016-2026 Hristo Gochkov, Mathieu Carbou, Emil Muratov, Will Miles + +// +// Shows how one handler object can serve multiple routes (Phase 2: route table). +// +// The addRoute() API decouples URI matching from the handler object: +// - The server's routing table stores { URI matcher, HTTP method, handler ptr } +// - One handler can be attached to many routes +// - URI/method matching is done by the server, not inside the handler +// +// Routes are evaluated before legacy handlers (registered via addHandler). +// +// The same handler can also be placed in PSRAM or static RAM (Phase 1). +// + +#include +#if defined(ESP32) || defined(LIBRETINY) +#include +#include +#if defined(ESP32) +#include +#endif +#elif defined(ESP8266) +#include +#include +#elif defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350) +#include +#include +#endif + +#include + +static AsyncWebServer server(80); + +// --------------------------------------------------------------------------- +// One handler, many routes — lives in static RAM (no heap allocation). +// The same object serves /hello, /hi, and /greet/* (Phase 1 + Phase 2). +// --------------------------------------------------------------------------- +static AsyncCallbackWebHandler greetHandler; + +// --------------------------------------------------------------------------- +// Another handler in PSRAM (ESP32 only), also serving multiple routes. +// --------------------------------------------------------------------------- +#if defined(ESP32) +static AsyncCallbackWebHandler *apiHandler = nullptr; +static void *apiHandlerMem = nullptr; +#endif + +// --------------------------------------------------------------------------- +// A classic server-owned handler (convenience API, Phase 1 backward compat). +// --------------------------------------------------------------------------- +// (see server.on() below) + +void setup() { + Serial.begin(115200); + while (!Serial) + ; + +#if ASYNCWEBSERVER_WIFI_SUPPORTED + WiFi.mode(WIFI_AP); + WiFi.softAP("esp-multi-route"); +#endif + + // --- 1. One handler, three routes (static RAM) ---------------------------- + // The handler only defines WHAT to do — the server's route table defines WHERE. + greetHandler.onRequest([](AsyncWebServerRequest *request) { + String msg = "Hello from " + request->url(); + request->send(200, "text/plain", msg); + }); + + // Same handler object on multiple routes: + server.addRoute(AsyncURIMatcher::exact("/hello"), HTTP_GET, greetHandler); + server.addRoute(AsyncURIMatcher::exact("/hi"), HTTP_GET, greetHandler); + server.addRoute(AsyncURIMatcher::prefix("/greet/"), HTTP_GET, greetHandler); + // greetHandler is registered non-owning — it lives in static RAM (.bss). + + // --- 2. PSRAM handler on multiple routes (ESP32 only) --------------------- +#if defined(ESP32) + if (psramFound()) { + apiHandlerMem = heap_caps_malloc(sizeof(AsyncCallbackWebHandler), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (apiHandlerMem) { + apiHandler = new (apiHandlerMem) AsyncCallbackWebHandler(); + apiHandler->onRequest([](AsyncWebServerRequest *request) { + String json = "{\"path\":\"" + request->url() + "\",\"region\":\"psram\"}"; + request->send(200, "application/json", json); + }); + // Same PSRAM handler on two routes — non-owning via pointer: + server.addRoute(AsyncURIMatcher::prefix("/api/"), HTTP_GET, apiHandler, false); + server.addRoute(AsyncURIMatcher::exact("/status"), HTTP_GET, apiHandler, false); + Serial.printf("API handler allocated in PSRAM at %p\n", apiHandlerMem); + } + } +#endif + + // --- 3. Different method on same handler ---------------------------------- + // The route table also filters by HTTP method — POST /hello is not matched + // by the GET routes above, it would fall through to the catch-all. + greetHandler.onBody([](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + // accept body but respond in handleRequest + }); + + // --- 4. Server-owned handler (classic convenience API) -------------------- + server.on("/ping", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send(200, "text/plain", "pong"); + }); + + // --- 5. Custom catch-all -------------------------------------------------- + server.onNotFound([](AsyncWebServerRequest *request) { + request->send(404, "text/plain", "Not found: " + request->url()); + }); + + server.begin(); + Serial.println("HTTP server started"); +#if ASYNCWEBSERVER_WIFI_SUPPORTED + Serial.printf("Connect to AP 'esp-multi-route' and open http://%s\n", + WiFi.softAPIP().toString().c_str()); +#endif + Serial.println("Try (all served by the SAME greetHandler object):"); + Serial.println(" http://192.168.4.1/hello → greetHandler"); + Serial.println(" http://192.168.4.1/hi → greetHandler (same object!)"); + Serial.println(" http://192.168.4.1/greet/world → greetHandler (prefix route)"); +#if defined(ESP32) + Serial.println("Try (PSRAM handler, ESP32 only):"); + Serial.println(" http://192.168.4.1/api/test → PSRAM apiHandler"); + Serial.println(" http://192.168.4.1/status → PSRAM apiHandler (same object!)"); +#endif + Serial.println("Try (server-owned convenience handler):"); + Serial.println(" http://192.168.4.1/ping → server.on() handler"); + Serial.println("Try (no route matches → catch-all):"); + Serial.println(" http://192.168.4.1/unknown → 404 catch-all"); + Serial.println(); + Serial.println("Note: POST /hello won't match (routes are GET-only) → 404 catch-all"); +} + +void loop() { + // Nothing to do — the async server handles everything. +} diff --git a/platformio.ini b/platformio.ini index 4e48443bc..6705bdfc5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -24,6 +24,7 @@ lib_dir = . ; src_dir = examples/arduino/MessagePack ; src_dir = examples/arduino/Middleware ; src_dir = examples/arduino/MultiPart +; src_dir = examples/arduino/MultiRoute ; src_dir = examples/arduino/Params ; src_dir = examples/arduino/PartitionDownloader src_dir = examples/arduino/PerfTests diff --git a/src/ESPAsyncWebServer.h b/src/ESPAsyncWebServer.h index 1b81e85fe..96a03aab0 100644 --- a/src/ESPAsyncWebServer.h +++ b/src/ESPAsyncWebServer.h @@ -1044,6 +1044,21 @@ class AsyncURIMatcher { bool matches(AsyncWebServerRequest *request) const; + /** + * @brief Compare two matchers by their string value. + * + * Used by removeRoute() to find a route to remove. Two matchers are equal + * if they have the same URI string value (case-sensitive comparison). + * Matcher type and modifiers are not compared — two matchers with the same + * string but different types are considered equal for removal purposes. + */ + bool operator==(const AsyncURIMatcher &other) const { + return _value == other._value; + } + bool operator!=(const AsyncURIMatcher &other) const { + return !(*this == other); + } + // static factory methods for common match types: // - AsyncURIMatcher::all() - matches everything // - AsyncURIMatcher::none() - matches nothing @@ -1689,9 +1704,20 @@ class AsyncWebServer : public AsyncMiddlewareChain { std::unique_ptr owned; // non-null if server owns it }; + // Route entry: decouples URI matching from the handler object (Phase 2). + // The server's routing table stores these so one handler can serve multiple routes. + // `hasMatcher` is false for legacy handlers registered via addHandler() (matched by canHandle()). + struct RouteEntry { + AsyncURIMatcher uri; // URI matcher (moved in, cheap for non-regex) + WebRequestMethodComposite method; // HTTP method mask + bool hasMatcher; // true if uri/method matching is done by the server + HandlerEntry entry; // ownership tracking (same as _handlers) + }; + AsyncServer _server; std::list> _rewrites; - std::list _handlers; + std::list _handlers; // legacy handlers (canHandle-based dispatch) + std::list _routes; // route table (uri+method-based dispatch) // May be nullptr if the user explicitly opted out of a catch-all handler. // When nullptr, unmatched requests get a bare 501 response (no handler chain runs). AsyncCallbackWebHandler *_catchAllHandler = nullptr; @@ -1868,6 +1894,55 @@ class AsyncWebServer : public AsyncMiddlewareChain { bool removeHandler(AsyncWebHandler *handler); + // ======================================================================== + // Phase 2: Route table API — decouple URI matching from handler objects. + // One handler can serve multiple routes. Routes are evaluated before + // legacy handlers (registered via addHandler). + // ======================================================================== + + /** + * @brief Register a route: URI matcher + HTTP method + handler. + * + * The server takes ownership of the handler by default. Pass + * `takeOwnership = false` for a non-owning registration. + * + * The same handler can be attached to multiple routes: + * @code + * AsyncCallbackWebHandler handler; + * handler.onRequest([](AsyncWebServerRequest *req) { req->send(200, "text/plain", "OK"); }); + * server.addRoute(AsyncURIMatcher::exact("/foo"), HTTP_GET, handler); + * server.addRoute(AsyncURIMatcher::exact("/bar"), HTTP_GET, handler); // same object + * @endcode + * + * Routes are evaluated in insertion order, before legacy handlers. + * + * @param uri URI matcher (e.g. AsyncURIMatcher::exact("/path")) + * @param method HTTP method mask (e.g. HTTP_GET, HTTP_GET | HTTP_POST) + * @param handler Pointer to the handler. Must remain valid while registered. + * @param takeOwnership If true (default), server owns and deletes the handler. + * @return Reference to the registered handler. + */ + AsyncWebHandler &addRoute(AsyncURIMatcher uri, WebRequestMethodComposite method, AsyncWebHandler *handler, bool takeOwnership = true); + + /** + * @brief Register a route with a user-managed handler (non-owning). + * + * @param uri URI matcher + * @param method HTTP method mask + * @param handler Reference to the handler. Must remain valid while registered. + * @return Reference to the registered handler (same object). + */ + AsyncWebHandler &addRoute(AsyncURIMatcher uri, WebRequestMethodComposite method, AsyncWebHandler &handler); + + /** + * @brief Remove a route by URI matcher and method. + * + * @param uri The URI matcher to match (exact copy comparison) + * @param method The HTTP method mask to match + * @return true if a route was removed + */ + bool removeRoute(const AsyncURIMatcher &uri, WebRequestMethodComposite method); + /** * @brief Replace the catch-all (not-found) handler with a user-provided one. * diff --git a/src/WebHandlerImpl.h b/src/WebHandlerImpl.h index fbff491fe..ccd84dba0 100644 --- a/src/WebHandlerImpl.h +++ b/src/WebHandlerImpl.h @@ -61,6 +61,10 @@ class AsyncCallbackWebHandler : public AsyncWebHandler { ArUploadHandlerFunction _onUpload; ArBodyHandlerFunction _onBody; bool _isRegex; + // When true, canHandle() returns true for any HTTP request — URI/method + // matching is delegated to the server's route table (Phase 2: addRoute()). + // Set automatically when registered via AsyncWebServer::addRoute(). + bool _routeManaged = false; public: AsyncCallbackWebHandler() : _uri(), _method(AsyncWebRequestMethod::HTTP_ALL), _onRequest(NULL), _onUpload(NULL), _onBody(NULL), _isRegex(false) {} @@ -78,6 +82,15 @@ class AsyncCallbackWebHandler : public AsyncWebHandler { _onBody = fn; } + // For internal use by AsyncWebServer::addRoute() — marks this handler as + // route-managed so canHandle() skips URI/method checks (the server does them). + void _setRouteManaged(bool v) { + _routeManaged = v; + } + bool _isRouteManaged() const { + return _routeManaged; + } + bool canHandle(AsyncWebServerRequest *request) const final; void handleRequest(AsyncWebServerRequest *request) final; void handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len, bool final) final; diff --git a/src/WebHandlers.cpp b/src/WebHandlers.cpp index 5a0977b2e..b41d27599 100644 --- a/src/WebHandlers.cpp +++ b/src/WebHandlers.cpp @@ -300,7 +300,16 @@ void AsyncCallbackWebHandler::setUri(AsyncURIMatcher uri) { } bool AsyncCallbackWebHandler::canHandle(AsyncWebServerRequest *request) const { - if (!_onRequest || !request->isHTTP() || !_method.matches(request->method())) { + if (!_onRequest || !request->isHTTP()) { + return false; + } + // Phase 2: if this handler is registered via addRoute(), the server already + // matched the URI and method — canHandle() only needs to confirm it's HTTP. + if (_routeManaged) { + return true; + } + // Phase 1 / legacy: handler does its own URI + method matching. + if (!_method.matches(request->method())) { return false; } return _uri.matches(request); diff --git a/src/WebServer.cpp b/src/WebServer.cpp index 3ccdd5b53..e390c4be0 100644 --- a/src/WebServer.cpp +++ b/src/WebServer.cpp @@ -127,6 +127,62 @@ bool AsyncWebServer::removeHandler(AsyncWebHandler *handler) { return true; } } + // Also check routes — a handler registered via addRoute() should be removable via removeHandler() + for (auto i = _routes.begin(); i != _routes.end(); ++i) { + if (i->entry.handler == handler) { + _routes.erase(i); + return true; + } + } + return false; +} + +// ======================================================================== +// Phase 2: Route table API +// ======================================================================== + +// Helper: if the handler is an AsyncCallbackWebHandler, mark it as route-managed +// so its canHandle() skips URI/method checks (the server's route table does them). +static void _markRouteManaged(AsyncWebHandler *handler) { + auto *cb = dynamic_cast(handler); + if (cb) { + cb->_setRouteManaged(true); + } +} + +AsyncWebHandler &AsyncWebServer::addRoute(AsyncURIMatcher uri, WebRequestMethodComposite method, AsyncWebHandler *handler, bool takeOwnership) { + _markRouteManaged(handler); + RouteEntry route; + route.uri = std::move(uri); + route.method = method; + route.hasMatcher = true; + route.entry.handler = handler; + if (takeOwnership) { + route.entry.owned.reset(handler); + } + _routes.push_back(std::move(route)); + return *handler; +} + +AsyncWebHandler &AsyncWebServer::addRoute(AsyncURIMatcher uri, WebRequestMethodComposite method, AsyncWebHandler &handler) { + _markRouteManaged(&handler); + RouteEntry route; + route.uri = std::move(uri); + route.method = method; + route.hasMatcher = true; + route.entry.handler = &handler; + // entry.owned stays null — non-owning registration + _routes.push_back(std::move(route)); + return handler; +} + +bool AsyncWebServer::removeRoute(const AsyncURIMatcher &uri, WebRequestMethodComposite method) { + for (auto i = _routes.begin(); i != _routes.end(); ++i) { + if (i->uri == uri && i->method == method) { + _routes.erase(i); + return true; + } + } return false; } @@ -161,6 +217,17 @@ void AsyncWebServer::_rewriteRequest(AsyncWebServerRequest *request) { } void AsyncWebServer::_attachHandler(AsyncWebServerRequest *request) { + // Phase 2: routes are evaluated first — URI + method matching is done by the server. + // The first matching route wins. + for (auto &route : _routes) { + if (route.method.matches(request->method()) && route.uri.matches(request)) { + if (route.entry.handler->filter(request)) { + request->setHandler(route.entry.handler); + return; + } + } + } + // Phase 1: legacy handlers — matched by canHandle() (backward compatible). for (auto &entry : _handlers) { if (entry.handler->filter(request) && entry.handler->canHandle(request)) { request->setHandler(entry.handler); @@ -243,6 +310,7 @@ void AsyncWebServer::setCatchAllHandler(AsyncCallbackWebHandler &handler) { void AsyncWebServer::reset() { _rewrites.clear(); _handlers.clear(); + _routes.clear(); if (_catchAllHandler) { _catchAllHandler->onRequest(NULL);