From 9e998f92a2caa7e007e18aeb460b2dcd2e0d32f7 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Wed, 6 May 2026 00:44:54 +1000 Subject: [PATCH] Add TemplateEngineAsyncWeb for safe ESPAsyncWebServer chunked responses Chunk callbacks may see maxLen==0 or need another tick before progress; plain 0 ends the body and breaks browsers. Provide helpers that use RESPONSE_TRY_AGAIN, bounded template render retries, and disconnect cleanup. Update the README and async examples to use the shared wrapper. --- README.md | 21 +-- examples/AsyncDashboardDemo/src/main.cpp | 23 +--- examples/StreamingAsync/src/main.cpp | 21 +-- include/TemplateEngineAsyncWeb.h | 167 +++++++++++++++++++++++ 4 files changed, 182 insertions(+), 50 deletions(-) create mode 100644 include/TemplateEngineAsyncWeb.h diff --git a/README.md b/README.md index 350861f..0cdc952 100644 --- a/README.md +++ b/README.md @@ -140,9 +140,12 @@ All public headers are re-exported from `TemplateEngine.h`, so typical sketches ### Async Streaming Pattern -When serving requests with ESPAsyncWebServer, give every request its own `TemplateContext` so chunked rendering cannot be corrupted by overlapping clients. Build and cache your `PlaceholderRegistry` once during setup, then share it across handlers. The same pattern powers the DeviceFramework web UI and the DFTE examples: +When serving requests with ESPAsyncWebServer, include `TemplateEngineAsyncWeb.h` and give every request its own `TemplateContext` so chunked rendering cannot be corrupted by overlapping clients. Build and cache your `PlaceholderRegistry` once during setup, then share it across handlers. The same pattern powers the DeviceFramework web UI and the DFTE examples: ```cpp +#include +#include + /** Global registry prepared during setup() */ std::shared_ptr registry; @@ -164,19 +167,9 @@ void streamTemplate(AsyncWebServerRequest* request, const char* rootTemplate) { request->onDisconnect([ctx]() mutable { ctx.reset(); }); - AsyncWebServerResponse* response = request->beginChunkedResponse( - "text/html; charset=utf-8", - [ctx](uint8_t* buffer, size_t maxLen, size_t) mutable -> size_t { - if (!ctx) { - return 0; - } - - size_t written = TemplateRenderer::renderNextChunk(*ctx, buffer, maxLen); - if (!written || TemplateRenderer::isComplete(*ctx) || TemplateRenderer::hasError(*ctx)) { - ctx.reset(); // prevent cross-request pollution - } - return written; - }); + AsyncWebServerResponse* response = + TemplateEngineAsyncWeb::beginSafeTemplateResponse( + request, "text/html; charset=utf-8", ctx, 128); response->addHeader("Cache-Control", "no-cache, no-store, must-revalidate"); response->addHeader("Pragma", "no-cache"); diff --git a/examples/AsyncDashboardDemo/src/main.cpp b/examples/AsyncDashboardDemo/src/main.cpp index 2fbf27d..db649fc 100644 --- a/examples/AsyncDashboardDemo/src/main.cpp +++ b/examples/AsyncDashboardDemo/src/main.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -219,25 +220,9 @@ void streamTemplate(AsyncWebServerRequest* request, ctx->setRegistry(registryPtr.get()); TemplateRenderer::initializeContext(*ctx, rootTemplate); - request->onDisconnect([ctx]() mutable { - ctx.reset(); - }); - - AsyncWebServerResponse* response = request->beginChunkedResponse( - "text/html; charset=utf-8", - [ctx](uint8_t* buffer, size_t maxLen, size_t /*index*/) mutable -> size_t { - if (!ctx) { - return 0; - } - - size_t written = TemplateRenderer::renderNextChunk(*ctx, buffer, maxLen); - - if (!written || TemplateRenderer::isComplete(*ctx) || TemplateRenderer::hasError(*ctx)) { - ctx.reset(); - } - - return written; - }); + AsyncWebServerResponse* response = + TemplateEngineAsyncWeb::beginSafeTemplateResponse( + request, "text/html; charset=utf-8", ctx, 128); response->addHeader("Cache-Control", "no-cache, no-store, must-revalidate"); response->addHeader("Pragma", "no-cache"); diff --git a/examples/StreamingAsync/src/main.cpp b/examples/StreamingAsync/src/main.cpp index af2448c..6998da4 100644 --- a/examples/StreamingAsync/src/main.cpp +++ b/examples/StreamingAsync/src/main.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -86,23 +87,9 @@ void streamTemplate(AsyncWebServerRequest* request, ctx->setRegistry(registryPtr.get()); TemplateRenderer::initializeContext(*ctx, tpl); - request->onDisconnect([ctx]() mutable { - ctx.reset(); - }); - - AsyncWebServerResponse* response = request->beginChunkedResponse( - "text/html; charset=utf-8", - [ctx](uint8_t* buffer, size_t maxLen, size_t) mutable -> size_t { - if (!ctx) { - return 0; - } - - size_t written = TemplateRenderer::renderNextChunk(*ctx, buffer, maxLen); - if (!written || TemplateRenderer::isComplete(*ctx) || TemplateRenderer::hasError(*ctx)) { - ctx.reset(); - } - return written; - }); + AsyncWebServerResponse* response = + TemplateEngineAsyncWeb::beginSafeTemplateResponse( + request, "text/html; charset=utf-8", ctx, 128); response->addHeader("Cache-Control", "no-cache"); request->send(response); diff --git a/include/TemplateEngineAsyncWeb.h b/include/TemplateEngineAsyncWeb.h new file mode 100644 index 0000000..37731ba --- /dev/null +++ b/include/TemplateEngineAsyncWeb.h @@ -0,0 +1,167 @@ +#ifndef TEMPLATE_ENGINE_ASYNC_WEB_H +#define TEMPLATE_ENGINE_ASYNC_WEB_H + +#include +#include +#include +#include "TemplateEngine.h" + +namespace TemplateEngineAsyncWeb { + +inline void yieldForChunkRetry() { +#if defined(ARDUINO_ARCH_ESP8266) + optimistic_yield(1000); +#else + yield(); +#endif +} + +template +inline bool isTemplateTerminal(const ContextT& context) { + return TemplateRenderer::isComplete(context) || TemplateRenderer::hasError(context); +} + +template +inline size_t renderTemplateChunkWithRetries(ContextT& context, + uint8_t* buffer, + size_t maxLen, + unsigned maxNoProgressRetries = 32) { + if (maxLen == 0) { + return RESPONSE_TRY_AGAIN; + } + + for (unsigned attempt = 0; attempt < maxNoProgressRetries; ++attempt) { + size_t written = TemplateRenderer::renderNextChunk(context, buffer, maxLen); + if (written > 0 || isTemplateTerminal(context)) { + return written; + } + + yieldForChunkRetry(); + } + + return RESPONSE_TRY_AGAIN; +} + +template +AsyncWebServerResponse* beginSafeChunkedResponse(AsyncWebServerRequest* request, + const char* contentType, + const std::shared_ptr& sharedState, + FillFn fill, + IsDoneFn isDone) { + request->onDisconnect([state = sharedState]() mutable { + state.reset(); + }); + + return request->beginChunkedResponse(contentType, + [state = sharedState, fill, isDone](uint8_t* buffer, size_t maxLen, size_t index) mutable -> size_t { + if (!state) { + return 0; + } + + if (maxLen == 0) { + return RESPONSE_TRY_AGAIN; + } + + size_t written = fill(*state, buffer, maxLen, index); + if (written == RESPONSE_TRY_AGAIN) { + return RESPONSE_TRY_AGAIN; + } + + if (!state) { + return 0; + } + + if (written > 0) { + return written; + } + + if (isDone(*state)) { + state.reset(); + return 0; + } + + yieldForChunkRetry(); + return RESPONSE_TRY_AGAIN; + }); +} + +template +AsyncWebServerResponse* beginSafeChunkedResponse(AsyncWebServerRequest* request, + const String& contentType, + const std::shared_ptr& sharedState, + FillFn fill, + IsDoneFn isDone) { + request->onDisconnect([state = sharedState]() mutable { + state.reset(); + }); + + return request->beginChunkedResponse(contentType, + [state = sharedState, fill, isDone](uint8_t* buffer, size_t maxLen, size_t index) mutable -> size_t { + if (!state) { + return 0; + } + + if (maxLen == 0) { + return RESPONSE_TRY_AGAIN; + } + + size_t written = fill(*state, buffer, maxLen, index); + if (written == RESPONSE_TRY_AGAIN) { + return RESPONSE_TRY_AGAIN; + } + + if (!state) { + return 0; + } + + if (written > 0) { + return written; + } + + if (isDone(*state)) { + state.reset(); + return 0; + } + + yieldForChunkRetry(); + return RESPONSE_TRY_AGAIN; + }); +} + +template +AsyncWebServerResponse* beginSafeTemplateResponse(AsyncWebServerRequest* request, + const char* contentType, + const std::shared_ptr& sharedContext, + unsigned maxNoProgressRetries = 32) { + return beginSafeChunkedResponse( + request, + contentType, + sharedContext, + [maxNoProgressRetries](ContextT& context, uint8_t* buffer, size_t maxLen, size_t /*index*/) -> size_t { + return renderTemplateChunkWithRetries(context, buffer, maxLen, maxNoProgressRetries); + }, + [](const ContextT& context) -> bool { + return isTemplateTerminal(context); + }); +} + +template +AsyncWebServerResponse* beginSafeTemplateResponse(AsyncWebServerRequest* request, + const String& contentType, + const std::shared_ptr& sharedContext, + unsigned maxNoProgressRetries = 32) { + return beginSafeChunkedResponse( + request, + contentType, + sharedContext, + [maxNoProgressRetries](ContextT& context, uint8_t* buffer, size_t maxLen, size_t /*index*/) -> size_t { + return renderTemplateChunkWithRetries(context, buffer, maxLen, maxNoProgressRetries); + }, + [](const ContextT& context) -> bool { + return isTemplateTerminal(context); + }); +} + +} // namespace TemplateEngineAsyncWeb + +#endif // TEMPLATE_ENGINE_ASYNC_WEB_H