DeterministicESPAsyncWebServer 1.2.0
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
DeterministicESPAsyncWebServer.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file DeterministicESPAsyncWebServer.cpp
6 * @brief Layer 7 (Application) — HTTP routing and request handler implementation.
7 *
8 * **Dispatch pipeline (called from DetWebServer::handle())**
9 * ```
10 * handle()
11 * └─ server_tick() ← drain FreeRTOS event queue
12 * └─ for each slot:
13 * PARSE_COMPLETE → match_and_execute()
14 * PARSE_ERROR → send(400)
15 * PARSE_ENTITY_TOO_LARGE → send(413)
16 * PARSE_URI_TOO_LONG → send(414)
17 * ```
18 *
19 * **Route table**
20 * Routes are stored in a fixed-size array of `Route` structs. Both exact
21 * and wildcard (suffix `*`) routes are supported; exact routes always take
22 * priority because the loop checks them in insertion order and returns on
23 * the first match.
24 *
25 * **PCB lifecycle in send() / send_empty()**
26 * Before writing to the PCB the slot is set to `CONN_FREE` and the pcb
27 * pointer is nulled. This mirrors the pattern in transport.cpp:
28 * 1. Save a local copy of the pcb pointer.
29 * 2. Detach our slot from it (`tcp_arg(pcb, nullptr)`).
30 * 3. Null out `conn->pcb` and set `conn->state = CONN_FREE`.
31 * 4. Do the write + close/abort on the saved local pointer.
32 *
33 * This means any lwIP error callback that fires mid-write sees the slot as
34 * already free and takes no action — preventing a double-free scenario.
35 */
36
38#if DETWS_ENABLE_WEBSOCKET
41#elif DETWS_ENABLE_AUTH
43#endif
44#include <string.h>
45#include <stdio.h>
46
47#if DETWS_ENABLE_WEBSOCKET
48// Magic GUID concatenated to the client key for the WS accept hash (RFC 6455 §4.2.2)
49static const char WS_MAGIC[] = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
50#endif
51
52/**
53 * @brief Convert an HTTP status code to its standard reason phrase.
54 *
55 * Covers the 18 codes that arise in typical REST micro-server usage.
56 * Unknown codes produce "Unknown" so callers never receive a null pointer.
57 *
58 * @param code HTTP status integer.
59 * @return Pointer to a string-literal reason phrase; never null.
60 */
61static const char *status_text(int code)
62{
63 switch (code)
64 {
65 case 200:
66 return "OK";
67 case 201:
68 return "Created";
69 case 204:
70 return "No Content";
71 case 301:
72 return "Moved Permanently";
73 case 302:
74 return "Found";
75 case 304:
76 return "Not Modified";
77 case 400:
78 return "Bad Request";
79 case 401:
80 return "Unauthorized";
81 case 403:
82 return "Forbidden";
83 case 404:
84 return "Not Found";
85 case 405:
86 return "Method Not Allowed";
87 case 408:
88 return "Request Timeout";
89 case 409:
90 return "Conflict";
91 case 413:
92 return "Payload Too Large";
93 case 414:
94 return "URI Too Long";
95 case 429:
96 return "Too Many Requests";
97 case 500:
98 return "Internal Server Error";
99 case 501:
100 return "Not Implemented";
101 case 503:
102 return "Service Unavailable";
103 default:
104 return "Unknown";
105 }
106}
107
108/**
109 * @brief Map a method string (from the parsed request line) to an HttpMethod enum.
110 *
111 * Falls through to HTTP_GET for any unrecognised method. This keeps the
112 * no-match path simple: the route table will fail to find an exact method
113 * match and will return 404 (or invoke the not-found handler).
114 *
115 * @param m Null-terminated method string, e.g. "POST".
116 * @return Matching HttpMethod enum value.
117 */
118static HttpMethod parse_method(const char *m)
119{
120 if (strcmp(m, "POST") == 0)
121 return HTTP_POST;
122 if (strcmp(m, "PUT") == 0)
123 return HTTP_PUT;
124 if (strcmp(m, "DELETE") == 0)
125 return HTTP_DELETE;
126 if (strcmp(m, "PATCH") == 0)
127 return HTTP_PATCH;
128 if (strcmp(m, "HEAD") == 0)
129 return HTTP_HEAD;
130 if (strcmp(m, "OPTIONS") == 0)
131 return HTTP_OPTIONS;
132 return HTTP_GET;
133}
134
137
138DetWebServer::DetWebServer() : _route_count(0), _port(0), _not_found_handler(nullptr), _cors_enabled(false)
139{
140 for (int i = 0; i < MAX_ROUTES; i++)
141 _routes[i] = {}; // zero all fields (auth_required, handlers, flags, etc.)
142 _cors_header_buf[0] = '\0';
143}
144
145int32_t DetWebServer::begin(uint16_t port, const WebServerConfig *cfg)
146{
147 _port = port;
148 for (uint8_t i = 0; i < MAX_CONNS; i++)
149 http_reset(i);
150#if DETWS_ENABLE_WEBSOCKET
151 ws_init();
152#endif
153#if DETWS_ENABLE_SSE
154 sse_init();
155#endif
156 return DeterministicAsyncTCP::init(port, cfg);
157}
158
160{
161 if (_port == 0)
162 return -1;
163 stop();
164 return begin(_port, cfg);
165}
166
168{
170 for (uint8_t i = 0; i < MAX_CONNS; i++)
171 http_reset(i);
172#if DETWS_ENABLE_WEBSOCKET
173 ws_init();
174#endif
175#if DETWS_ENABLE_SSE
176 sse_init();
177#endif
178}
179
180/**
181 * @brief Register a route in the route table.
182 *
183 * Paths are stored null-terminated and truncated to MAX_PATH_LEN. The
184 * trailing character of the stored path is inspected to detect wildcard
185 * routes: any path ending in `*` is treated as a prefix match.
186 *
187 * Registrations beyond MAX_ROUTES are silently ignored — callers should
188 * verify return values if overflow is a concern.
189 *
190 * @param path URL path to match, e.g. "/api/*".
191 * @param method HTTP method that triggers this route.
192 * @param callback Handler invoked with (slot_id, request).
193 */
194static void fill_route_base(Route *r, const char *path)
195{
196 strncpy(r->path, path, MAX_PATH_LEN - 1);
197 r->path[MAX_PATH_LEN - 1] = '\0';
198 r->is_active = true;
199 size_t len = strlen(r->path);
200 r->is_wildcard = (len > 0 && r->path[len - 1] == '*');
201}
202
203void DetWebServer::on(const char *path, HttpMethod method, Handler callback)
204{
205 if (_route_count >= MAX_ROUTES)
206 return;
207 Route *r = &_routes[_route_count++];
208 fill_route_base(r, path);
209 r->type = ROUTE_HTTP;
210 r->method = method;
211 r->callback = callback;
212}
213
214#if DETWS_ENABLE_AUTH
215void DetWebServer::on(const char *path, HttpMethod method, Handler callback,
216 const char *realm, const char *user, const char *pass)
217{
218 if (_route_count >= MAX_ROUTES)
219 return;
220 Route *r = &_routes[_route_count++];
221 fill_route_base(r, path);
222 r->type = ROUTE_HTTP;
223 r->method = method;
224 r->callback = callback;
225 r->auth_required = true;
226 strncpy(r->auth_realm, realm, MAX_AUTH_LEN - 1);
227 r->auth_realm[MAX_AUTH_LEN - 1] = '\0';
228 strncpy(r->auth_user, user, MAX_AUTH_LEN - 1);
229 r->auth_user[MAX_AUTH_LEN - 1] = '\0';
230 strncpy(r->auth_pass, pass, MAX_AUTH_LEN - 1);
231 r->auth_pass[MAX_AUTH_LEN - 1] = '\0';
232}
233#endif // DETWS_ENABLE_AUTH
234
235#if DETWS_ENABLE_WEBSOCKET
236void DetWebServer::on_ws(const char *path,
237 WsConnectHandler on_connect,
238 WsMessageHandler on_message,
239 WsCloseHandler on_close)
240{
241 if (_route_count >= MAX_ROUTES)
242 return;
243 Route *r = &_routes[_route_count++];
244 fill_route_base(r, path);
245 r->type = ROUTE_WS;
246 r->ws_connect = on_connect;
247 r->ws_message = on_message;
248 r->ws_close = on_close;
249}
250#endif // DETWS_ENABLE_WEBSOCKET
251
252#if DETWS_ENABLE_SSE
253void DetWebServer::on_sse(const char *path, SseConnectHandler on_connect)
254{
255 if (_route_count >= MAX_ROUTES)
256 return;
257 Route *r = &_routes[_route_count++];
258 fill_route_base(r, path);
259 r->type = ROUTE_SSE;
260 r->sse_connect = on_connect;
261}
262#endif // DETWS_ENABLE_SSE
263
265{
266 _not_found_handler = callback;
267}
268
269/**
270 * @brief Enable CORS and pre-build the Access-Control response header block.
271 *
272 * The header string is constructed once here rather than at response time to
273 * avoid repeated snprintf calls on the hot path. It is stored in
274 * `_cors_header_buf[]` and injected verbatim into every response when
275 * `_cors_enabled` is true.
276 *
277 * Passing an empty or null origin disables CORS without clearing the buffer —
278 * only the `_cors_enabled` flag matters at dispatch time.
279 *
280 * @param origin Value for the Access-Control-Allow-Origin header, e.g. "*".
281 */
282void DetWebServer::set_cors(const char *origin)
283{
284 if (!origin || origin[0] == '\0')
285 {
286 _cors_enabled = false;
287 _cors_header_buf[0] = '\0';
288 return;
289 }
290 snprintf(_cors_header_buf, CORS_HDR_BUF_SIZE,
291 "Access-Control-Allow-Origin: %s\r\n"
292 "Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS\r\n"
293 "Access-Control-Allow-Headers: Content-Type\r\n",
294 origin);
295 _cors_enabled = true;
296}
297
298/**
299 * @brief Test whether a route path matches an incoming request path.
300 *
301 * Exact routes use strcmp (full-string equality). Wildcard routes use
302 * strncmp against the prefix up to (but not including) the trailing `*`.
303 *
304 * @param route Registered route path, potentially ending in `*`.
305 * @param is_wildcard True when the route was registered with a trailing `*`.
306 * @param req_path Incoming request path from the parsed HTTP request line.
307 * @return True if the route matches the request path.
308 */
309bool DetWebServer::path_matches(const char *route, bool is_wildcard, const char *req_path)
310{
311 if (!is_wildcard)
312 return strcmp(route, req_path) == 0;
313
314 // Prefix match: compare everything up to (but not including) the '*'
315 size_t prefix_len = strlen(route) - 1;
316 return strncmp(route, req_path, prefix_len) == 0;
317}
318
319/**
320 * @brief Main application tick — tick the session layer then dispatch completed requests.
321 *
322 * Call this repeatedly from loop(). Each call:
323 * 1. Calls server_tick() which runs timeout sweeps + drains the event queue.
324 * 2. Walks all slots; any in PARSE_COMPLETE is dispatched via match_and_execute().
325 * 3. Any slot left in PARSE_COMPLETE after dispatch (i.e., callback did not
326 * send a response) is reset so it doesn't block the slot.
327 * 4. Any slot in PARSE_ERROR receives an automatic 400 response.
328 * 5. Any slot in PARSE_ENTITY_TOO_LARGE receives an automatic 413 response.
329 * 6. Any slot in PARSE_URI_TOO_LONG receives an automatic 414 response.
330 */
332{
333 server_tick();
334
335 for (uint8_t i = 0; i < MAX_CONNS; i++)
336 {
337#if DETWS_ENABLE_WEBSOCKET
338 // WebSocket slot — drain ring buffer and dispatch ready frames
339 WsConn *ws = ws_find(i);
340 if (ws)
341 {
342 ws_parse(ws);
343
344 if (ws->parse_state == WS_FRAME_READY)
345 {
346 for (uint8_t r = 0; r < _route_count; r++)
347 {
348 if (_routes[r].type == ROUTE_WS && _routes[r].ws_message)
349 {
350 _routes[r].ws_message(ws->ws_id);
351 break;
352 }
353 }
354 ws_reset_frame(ws);
355 }
356 else if (ws->parse_state == WS_CLOSED || ws->parse_state == WS_ERROR)
357 {
358 for (uint8_t r = 0; r < _route_count; r++)
359 {
360 if (_routes[r].type == ROUTE_WS && _routes[r].ws_close)
361 {
362 _routes[r].ws_close(ws->ws_id);
363 break;
364 }
365 }
366 ws_free(i);
367 http_reset(i);
368 }
369 continue; // slot is owned by WS; skip HTTP dispatch
370 }
371#endif // DETWS_ENABLE_WEBSOCKET
372
373#if DETWS_ENABLE_SSE
374 // SSE slot — connection stays open, nothing to parse from client
375 if (sse_find(i))
376 continue;
377#endif // DETWS_ENABLE_SSE
378
379 // HTTP slot
380 if (http_pool[i].parse_state == PARSE_COMPLETE)
381 {
382 match_and_execute(i);
383 if (http_pool[i].parse_state == PARSE_COMPLETE)
384 http_reset(i);
385 }
386 else if (http_pool[i].parse_state == PARSE_ERROR)
387 {
388 send(i, 400, "text/plain", "Bad Request");
389 }
390 else if (http_pool[i].parse_state == PARSE_ENTITY_TOO_LARGE)
391 {
392 send(i, 413, "text/plain", "Payload Too Large");
393 }
394 else if (http_pool[i].parse_state == PARSE_URI_TOO_LONG)
395 {
396 send(i, 414, "text/plain", "URI Too Long");
397 }
398 }
399}
400
401// ---------------------------------------------------------------------------
402// Diagnostic endpoint
403// ---------------------------------------------------------------------------
404
405#if DETWS_ENABLE_DIAG
406void DetWebServer::diag(uint8_t slot_id)
407{
408 send(slot_id, 200, "application/json", DETWS_DIAG_JSON);
409}
410#endif
411
412// ---------------------------------------------------------------------------
413// WebSocket handshake helpers
414// ---------------------------------------------------------------------------
415
416#if DETWS_ENABLE_WEBSOCKET
417/**
418 * @brief Compute the Sec-WebSocket-Accept value for the HTTP 101 response.
419 *
420 * Concatenates the client key with the RFC 6455 magic GUID, SHA-1 hashes
421 * the result, and base64-encodes the 20-byte digest into @p out.
422 * @p out must be at least 29 bytes (28 base64 chars + null terminator).
423 */
424static const size_t WS_MAX_KEY_LEN = 64;
425
426static bool ws_accept_key(const char *client_key, char *out)
427{
428 size_t key_len = strnlen(client_key, WS_MAX_KEY_LEN + 1);
429 if (key_len > WS_MAX_KEY_LEN)
430 {
431 out[0] = '\0';
432 return false;
433 }
434 size_t magic_len = sizeof(WS_MAGIC) - 1;
435 char concat[WS_MAX_KEY_LEN + sizeof(WS_MAGIC)];
436 memcpy(concat, client_key, key_len);
437 memcpy(concat + key_len, WS_MAGIC, magic_len);
438
439 uint8_t digest[SHA1_DIGEST_LEN];
440 sha1((const uint8_t *)concat, key_len + magic_len, digest);
441 base64_encode(digest, SHA1_DIGEST_LEN, out);
442 return true;
443}
444
445/**
446 * @brief Send the HTTP 101 Switching Protocols handshake and upgrade the slot.
447 *
448 * Does NOT close the TCP connection -- that is intentional. The slot moves
449 * from HTTP parse ownership to WS frame parse ownership.
450 */
451static bool ws_do_upgrade(uint8_t slot_id, HttpReq *req,
452 WsConnectHandler on_connect)
453{
454 const char *client_key = http_get_header(req, "Sec-WebSocket-Key");
455 if (!client_key)
456 return false;
457
458 char accept[32];
459 if (!ws_accept_key(client_key, accept))
460 return false;
461
462 TcpConn *conn = &conn_pool[slot_id];
463 if (conn->state != CONN_ACTIVE || !conn->pcb)
464 return false;
465
466 char hdr[WS_HDR_BUF_SIZE];
467 int hlen = snprintf(hdr, sizeof(hdr),
468 "HTTP/1.1 101 Switching Protocols\r\n"
469 "Upgrade: websocket\r\n"
470 "Connection: Upgrade\r\n"
471 "Sec-WebSocket-Accept: %s\r\n\r\n",
472 accept);
473
474 tcp_write(conn->pcb, hdr, (u16_t)hlen, TCP_WRITE_FLAG_COPY);
475 tcp_output(conn->pcb);
476
477 // Reset HTTP parser but keep the TCP slot -- WS owns it now
478 http_reset(slot_id);
479
480 WsConn *ws = ws_alloc(slot_id);
481 if (!ws)
482 {
483 // No WS slot available -- abort the connection
484 tcp_arg(conn->pcb, nullptr);
485 conn->state = CONN_FREE;
486 conn->pcb = nullptr;
487 return false;
488 }
489
490 if (on_connect)
491 on_connect(ws->ws_id);
492
493 return true;
494}
495#endif // DETWS_ENABLE_WEBSOCKET
496
497// ---------------------------------------------------------------------------
498// SSE upgrade helper
499// ---------------------------------------------------------------------------
500
501#if DETWS_ENABLE_SSE
502/**
503 * @brief Send the HTTP 200 + SSE headers and promote the slot to SSE mode.
504 */
505static bool sse_do_upgrade(uint8_t slot_id, HttpReq *req,
506 SseConnectHandler on_connect)
507{
508 TcpConn *conn = &conn_pool[slot_id];
509 if (conn->state != CONN_ACTIVE || !conn->pcb)
510 return false;
511
512 static const char SSE_HDR[] =
513 "HTTP/1.1 200 OK\r\n"
514 "Content-Type: text/event-stream\r\n"
515 "Cache-Control: no-cache\r\n"
516 "Connection: keep-alive\r\n\r\n";
517
518 tcp_write(conn->pcb, SSE_HDR, (u16_t)(sizeof(SSE_HDR) - 1),
519 TCP_WRITE_FLAG_COPY);
520 tcp_output(conn->pcb);
521
522 // Reset HTTP parser; SSE keeps the TCP slot open
523 const char *path = req->path;
524 http_reset(slot_id);
525
526 SseConn *sse = sse_alloc(slot_id, path);
527 if (!sse)
528 {
529 tcp_arg(conn->pcb, nullptr);
530 conn->state = CONN_FREE;
531 conn->pcb = nullptr;
532 return false;
533 }
534
535 if (on_connect)
536 on_connect(sse->sse_id);
537
538 return true;
539}
540#endif // DETWS_ENABLE_SSE
541
542// ---------------------------------------------------------------------------
543// Route dispatch
544// ---------------------------------------------------------------------------
545
546void DetWebServer::match_and_execute(uint8_t slot_id)
547{
548 HttpReq *req = &http_pool[slot_id];
549 HttpMethod method = parse_method(req->method);
550
551 // CORS preflight
552 if (method == HTTP_OPTIONS && _cors_enabled)
553 {
554 send_empty(slot_id, 204);
555 return;
556 }
557
558 // RFC 7230 §3.3.1: reject Transfer-Encoding
559 if (http_get_header(req, "Transfer-Encoding") != nullptr)
560 {
561 send(slot_id, 501, "text/plain", "Not Implemented");
562 return;
563 }
564
565#if DETWS_ENABLE_WEBSOCKET
566 const char *upgrade_hdr = http_get_header(req, "Upgrade");
567 bool is_ws_upgrade = (method == HTTP_GET)
568 && upgrade_hdr
569 && (strcasecmp(upgrade_hdr, "websocket") == 0);
570#endif
571
572 for (uint8_t i = 0; i < _route_count; i++)
573 {
574 Route *r = &_routes[i];
575 if (!r->is_active)
576 continue;
577 if (!path_matches(r->path, r->is_wildcard, req->path))
578 continue;
579
580#if DETWS_ENABLE_WEBSOCKET
581 if (r->type == ROUTE_WS)
582 {
583 if (!is_ws_upgrade)
584 {
585 send(slot_id, 400, "text/plain", "WebSocket upgrade required");
586 return;
587 }
588 if (!ws_do_upgrade(slot_id, req, r->ws_connect))
589 send(slot_id, 503, "text/plain", "Service Unavailable");
590 return;
591 }
592#endif // DETWS_ENABLE_WEBSOCKET
593
594#if DETWS_ENABLE_SSE
595 if (r->type == ROUTE_SSE)
596 {
597 if (!sse_do_upgrade(slot_id, req, r->sse_connect))
598 send(slot_id, 503, "text/plain", "Service Unavailable");
599 return;
600 }
601#endif // DETWS_ENABLE_SSE
602
603 // ROUTE_HTTP
604 if (r->method != method)
605 continue;
606#if DETWS_ENABLE_AUTH
607 if (r->auth_required && !check_basic_auth(slot_id, req, r))
608 {
609 send_unauth(slot_id, r->auth_realm);
610 return;
611 }
612#endif // DETWS_ENABLE_AUTH
613 r->callback(slot_id, req);
614 return;
615 }
616
617 if (_not_found_handler)
618 _not_found_handler(slot_id, req);
619 else
620 send(slot_id, 404, "text/plain", "Not Found");
621}
622
623/**
624 * @brief Build and transmit an HTTP response with a body.
625 *
626 * Uses a 512-byte stack buffer for headers. CORS headers are appended when
627 * `_cors_enabled`. The slot is freed (state → CONN_FREE, pcb → nullptr)
628 * *before* the tcp_write + tcp_close sequence to ensure any error callback
629 * that lwIP fires during the write sees the slot as already released.
630 *
631 * If the slot's connection is not active (e.g., already timed-out or the
632 * PCB is null) the slot is reset and the function returns without writing.
633 *
634 * @param slot_id Connection slot index.
635 * @param code HTTP status code, e.g. 200.
636 * @param content_type MIME type string, e.g. "application/json".
637 * @param payload Null-terminated body string to send.
638 */
639void DetWebServer::send(uint8_t slot_id, int code, const char *content_type, const char *payload)
640{
641 TcpConn *conn = &conn_pool[slot_id];
642 if (conn->state != CONN_ACTIVE || conn->pcb == nullptr)
643 {
644 http_reset(slot_id);
645 return;
646 }
647
648 int payload_len = (int)strlen(payload);
649
650 char header[RESP_HDR_BUF_SIZE];
651 int hlen = snprintf(header, sizeof(header),
652 "HTTP/1.1 %d %s\r\n"
653 "Content-Type: %s\r\n"
654 "Content-Length: %d\r\n"
655 "%s"
656 "Connection: close\r\n\r\n",
657 code, status_text(code), content_type, payload_len,
658 _cors_enabled ? _cors_header_buf : "");
659
660 struct tcp_pcb *pcb = conn->pcb;
661 /*
662 * Detach and free the slot before writing. Any lwIP error callback
663 * firing during tcp_write will see CONN_FREE and take no action.
664 */
665 tcp_arg(pcb, nullptr);
666 conn->state = CONN_FREE;
667 conn->pcb = nullptr;
668
669 tcp_write(pcb, header, (u16_t)hlen, TCP_WRITE_FLAG_COPY);
670 tcp_write(pcb, payload, (u16_t)payload_len, TCP_WRITE_FLAG_COPY);
671 tcp_output(pcb);
672
673 if (tcp_close(pcb) != ERR_OK)
674 tcp_abort(pcb);
675
676 http_reset(slot_id);
677}
678
679/**
680 * @brief Build and transmit an HTTP response with no body.
681 *
682 * Used for CORS preflight (204) and any response where only status headers
683 * are needed. Behaves identically to send() regarding slot lifecycle and
684 * PCB ownership transfer — the slot is freed before the lwIP write call.
685 *
686 * @param slot_id Connection slot index.
687 * @param code HTTP status code, e.g. 204.
688 */
689void DetWebServer::send_empty(uint8_t slot_id, int code)
690{
691 TcpConn *conn = &conn_pool[slot_id];
692 if (conn->state != CONN_ACTIVE || conn->pcb == nullptr)
693 {
694 http_reset(slot_id);
695 return;
696 }
697
698 char header[RESP_HDR_BUF_SIZE];
699 int hlen = snprintf(header, sizeof(header),
700 "HTTP/1.1 %d %s\r\n"
701 "Content-Length: 0\r\n"
702 "%s"
703 "Connection: close\r\n\r\n",
704 code, status_text(code), _cors_enabled ? _cors_header_buf : "");
705
706 struct tcp_pcb *pcb = conn->pcb;
707 tcp_arg(pcb, nullptr);
708 conn->state = CONN_FREE;
709 conn->pcb = nullptr;
710
711 tcp_write(pcb, header, (u16_t)hlen, TCP_WRITE_FLAG_COPY);
712 tcp_output(pcb);
713
714 if (tcp_close(pcb) != ERR_OK)
715 tcp_abort(pcb);
716
717 http_reset(slot_id);
718}
719
720// ---------------------------------------------------------------------------
721// WebSocket public API
722// ---------------------------------------------------------------------------
723
724#if DETWS_ENABLE_WEBSOCKET
725void DetWebServer::ws_send_text(uint8_t ws_id, const char *text)
726{
727 if (ws_id >= MAX_WS_CONNS || !ws_pool[ws_id].active)
728 return;
729 WsConn *ws = &ws_pool[ws_id];
730 if (ws->parse_state == WS_CLOSED || ws->parse_state == WS_ERROR)
731 return;
732 uint16_t len = (uint16_t)strlen(text);
733 if (ws_send_frame(ws, WS_OP_TEXT, (const uint8_t *)text, len))
734 {
735 TcpConn *conn = &conn_pool[ws->slot_id];
736 if (conn->pcb)
737 tcp_output(conn->pcb);
738 }
739}
740
741void DetWebServer::ws_send_binary(uint8_t ws_id, const uint8_t *data, uint16_t len)
742{
743 if (ws_id >= MAX_WS_CONNS || !ws_pool[ws_id].active)
744 return;
745 WsConn *ws = &ws_pool[ws_id];
746 if (ws->parse_state == WS_CLOSED || ws->parse_state == WS_ERROR)
747 return;
748 if (ws_send_frame(ws, WS_OP_BINARY, data, len))
749 {
750 TcpConn *conn = &conn_pool[ws->slot_id];
751 if (conn->pcb)
752 tcp_output(conn->pcb);
753 }
754}
755
756void DetWebServer::ws_disconnect(uint8_t ws_id)
757{
758 if (ws_id >= MAX_WS_CONNS || !ws_pool[ws_id].active)
759 return;
760 WsConn *ws = &ws_pool[ws_id];
762 TcpConn *conn = &conn_pool[ws->slot_id];
763 if (conn->pcb)
764 tcp_output(conn->pcb);
765 // handle() detects WS_CLOSED next tick and fires ws_close callback
766}
767#endif // DETWS_ENABLE_WEBSOCKET
768
769// ---------------------------------------------------------------------------
770// Server-Sent Events public API
771// ---------------------------------------------------------------------------
772
773#if DETWS_ENABLE_SSE
774void DetWebServer::sse_send(uint8_t sse_id, const char *data,
775 const char *event, const char *id)
776{
777 if (sse_id >= MAX_SSE_CONNS || !sse_pool[sse_id].active)
778 return;
779 SseConn *sse = &sse_pool[sse_id];
780 if (sse_write(sse, data, event, id))
781 {
782 TcpConn *conn = &conn_pool[sse->slot_id];
783 if (conn->pcb)
784 tcp_output(conn->pcb);
785 }
786}
787
788void DetWebServer::sse_broadcast(const char *path, const char *data,
789 const char *event, const char *id)
790{
791 for (int i = 0; i < MAX_SSE_CONNS; i++)
792 {
793 if (!sse_pool[i].active)
794 continue;
795 if (strcmp(sse_pool[i].path, path) != 0)
796 continue;
797 SseConn *sse = &sse_pool[i];
798 if (sse_write(sse, data, event, id))
799 {
800 TcpConn *conn = &conn_pool[sse->slot_id];
801 if (conn->pcb)
802 tcp_output(conn->pcb);
803 }
804 }
805}
806#endif // DETWS_ENABLE_SSE
807
808// ---------------------------------------------------------------------------
809// Basic Auth helpers
810// ---------------------------------------------------------------------------
811
812#if DETWS_ENABLE_AUTH
813void DetWebServer::send_unauth(uint8_t slot_id, const char *realm)
814{
815 TcpConn *conn = &conn_pool[slot_id];
816 if (conn->state != CONN_ACTIVE || !conn->pcb)
817 {
818 http_reset(slot_id);
819 return;
820 }
821
822 static const char body[] = "Unauthorized";
823 char header[RESP_HDR_BUF_SIZE];
824 int hlen = snprintf(header, sizeof(header),
825 "HTTP/1.1 401 Unauthorized\r\n"
826 "WWW-Authenticate: Basic realm=\"%s\"\r\n"
827 "Content-Type: text/plain\r\n"
828 "Content-Length: %d\r\n"
829 "%s"
830 "Connection: close\r\n\r\n",
831 realm, (int)(sizeof(body) - 1),
832 _cors_enabled ? _cors_header_buf : "");
833
834 struct tcp_pcb *pcb = conn->pcb;
835 tcp_arg(pcb, nullptr);
836 conn->state = CONN_FREE;
837 conn->pcb = nullptr;
838
839 tcp_write(pcb, header, (u16_t)hlen, TCP_WRITE_FLAG_COPY);
840 tcp_write(pcb, body, (u16_t)(sizeof(body) - 1), TCP_WRITE_FLAG_COPY);
841 tcp_output(pcb);
842
843 if (tcp_close(pcb) != ERR_OK)
844 tcp_abort(pcb);
845
846 http_reset(slot_id);
847}
848
849bool DetWebServer::check_basic_auth(uint8_t /*slot_id*/, HttpReq *req, const Route *r)
850{
851 const char *auth_hdr = http_get_header(req, "Authorization");
852 if (!auth_hdr || strncmp(auth_hdr, "Basic ", 6) != 0)
853 return false;
854
855 uint8_t decoded[MAX_AUTH_LEN * 2 + 2];
856 size_t n = base64_decode(auth_hdr + 6, decoded);
857 if (n == 0 || n >= sizeof(decoded))
858 return false;
859 decoded[n] = '\0';
860
861 const char *colon = (const char *)memchr(decoded, ':', n);
862 if (!colon)
863 return false;
864
865 size_t ulen = (size_t)(colon - (const char *)decoded);
866 const char *pass = colon + 1;
867
868 return (ulen == strlen(r->auth_user))
869 && (memcmp(decoded, r->auth_user, ulen) == 0)
870 && (strcmp(pass, r->auth_pass) == 0);
871}
872#endif // DETWS_ENABLE_AUTH
873
874// ---------------------------------------------------------------------------
875// File serving
876// ---------------------------------------------------------------------------
877
878#if DETWS_ENABLE_FILE_SERVING
879void DetWebServer::serve_file(uint8_t slot_id, fs::FS &file_sys,
880 const char *fs_path, const char *content_type)
881{
882 fs::File f = file_sys.open(fs_path, "r");
883 if (!f)
884 {
885 send(slot_id, 404, "text/plain", "Not Found");
886 return;
887 }
888
889 TcpConn *conn = &conn_pool[slot_id];
890 if (conn->state != CONN_ACTIVE || !conn->pcb)
891 {
892 f.close();
893 http_reset(slot_id);
894 return;
895 }
896
897 size_t file_size = f.size();
898
899 char header[RESP_HDR_BUF_SIZE];
900 int hlen = snprintf(header, sizeof(header),
901 "HTTP/1.1 200 OK\r\n"
902 "Content-Type: %s\r\n"
903 "Content-Length: %d\r\n"
904 "%s"
905 "Connection: close\r\n\r\n",
906 content_type, (int)file_size,
907 _cors_enabled ? _cors_header_buf : "");
908
909 struct tcp_pcb *pcb = conn->pcb;
910 tcp_arg(pcb, nullptr);
911 conn->state = CONN_FREE;
912 conn->pcb = nullptr;
913
914 tcp_write(pcb, header, (u16_t)hlen, TCP_WRITE_FLAG_COPY);
915
916 uint8_t chunk[FILE_CHUNK_SIZE];
917 size_t n;
918 while ((n = f.read(chunk, sizeof(chunk))) > 0)
919 tcp_write(pcb, chunk, (u16_t)n, TCP_WRITE_FLAG_COPY);
920
921 tcp_output(pcb);
922
923 if (tcp_close(pcb) != ERR_OK)
924 tcp_abort(pcb);
925
926 f.close();
927 http_reset(slot_id);
928}
929#endif // DETWS_ENABLE_FILE_SERVING
#define WS_HDR_BUF_SIZE
Stack buffer for the HTTP 101 Switching Protocols response sent during the WebSocket handshake.
#define RESP_HDR_BUF_SIZE
Stack buffer for HTTP response header lines in send() / send_empty() / send_unauth() / serve_file().
#define FILE_CHUNK_SIZE
Bytes read from the filesystem and passed to tcp_write() per loop().
#define MAX_PATH_LEN
Maximum URL path length (including leading /).
#define MAX_AUTH_LEN
Maximum username or password length for HTTP Basic Authentication.
#define MAX_SSE_CONNS
Maximum simultaneous SSE connections.
#define CORS_HDR_BUF_SIZE
Size of the pre-built CORS header block stored in DetWebServer.
#define MAX_CONNS
Maximum simultaneous TCP connections.
#define MAX_WS_CONNS
Maximum simultaneous WebSocket connections.
#define MAX_ROUTES
Maximum simultaneously registered routes.
Layer 7 (Application) — public HTTP routing API.
@ ROUTE_HTTP
Standard HTTP request/response.
HttpMethod
HTTP request methods supported by the router.
@ HTTP_PUT
Idempotent replace.
@ HTTP_DELETE
Idempotent delete.
@ HTTP_GET
Safe, idempotent read.
@ HTTP_OPTIONS
Capability query / CORS preflight.
@ HTTP_POST
Non-idempotent create / action.
@ HTTP_HEAD
Same as GET but no response body.
@ HTTP_PATCH
Partial update.
void(* Handler)(uint8_t slot_id, HttpReq *request)
Callback signature for HTTP request handlers.
void base64_encode(const uint8_t *src, size_t src_len, char *dst)
Encode src_len bytes of src as Base64.
Definition base64.cpp:23
size_t base64_decode(const char *src, uint8_t *dst)
Decode a null-terminated Base64 string.
Definition base64.cpp:31
Base64 encoder/decoder.
void on(const char *path, HttpMethod method, Handler callback)
Register a route handler.
void handle()
Drive the server — call every Arduino loop() iteration.
int32_t restart(const WebServerConfig *cfg=nullptr)
Hard-reset all connections and reinitialise the server on the same port that was passed to begin().
int32_t begin(uint16_t port, const WebServerConfig *cfg=nullptr)
Initialise all connection slots and open the TCP listener.
void send(uint8_t slot_id, int code, const char *content_type, const char *payload)
Send an HTTP response with a body and close the connection.
void on_not_found(Handler callback)
Register a fallback handler for unmatched requests.
void send_empty(uint8_t slot_id, int code)
Send a headers-only HTTP response and close the connection.
void set_cors(const char *origin)
Enable CORS by pre-building the Access-Control headers.
static bool heap_available()
True if the largest contiguous free heap block >= heap_needed().
static size_t heap_needed()
Bytes of contiguous heap that begin() will allocate.
DetWebServer()
Construct a DetWebServer with an empty routing table.
void stop()
Gracefully stop the server.
static bool heap_available()
Always returns true — no heap allocation means no pre-flight needed.
Definition transport.cpp:57
static int32_t init(uint16_t port, const WebServerConfig *cfg=nullptr)
Initialise the TCP stack, create the event queue, and begin listening.
Definition transport.cpp:62
static size_t heap_needed()
Always returns 0 — the library makes no heap allocations.
Definition transport.cpp:52
static void stop()
Stop the server: abort all connections, close the listener, free the queue.
const char * http_get_header(const HttpReq *req, const char *key)
Look up a header value by name (case-insensitive).
HttpReq http_pool[MAX_CONNS]
Pool of parser contexts, one per transport slot.
@ PARSE_COMPLETE
Full request parsed; ready for dispatch.
Definition http_parser.h:63
@ PARSE_URI_TOO_LONG
Path exceeds MAX_PATH_LEN → 414.
Definition http_parser.h:66
@ PARSE_ENTITY_TOO_LARGE
Content-Length > BODY_BUF_SIZE → 413.
Definition http_parser.h:65
@ PARSE_ERROR
Unrecoverable parse failure → 400.
Definition http_parser.h:64
void http_reset(uint8_t slot_id)
Reset the HTTP parser for a connection slot.
void server_tick()
Drive the session layer for one Arduino loop iteration.
Definition session.cpp:15
void sha1(const uint8_t *data, size_t len, uint8_t digest[SHA1_DIGEST_LEN])
Compute a SHA-1 digest over an arbitrary byte buffer.
Definition sha1.cpp:24
Software SHA-1 implementation — no platform dependencies.
#define SHA1_DIGEST_LEN
SHA-1 digest length in bytes.
Definition sha1.h:22
void sse_init()
Initialise all SSE pool slots to inactive.
Definition sse.cpp:16
bool sse_write(SseConn *sse, const char *data, const char *event, const char *id)
Write one SSE event record to a client.
Definition sse.cpp:66
SseConn sse_pool[MAX_SSE_CONNS]
Pool of SSE connection state, one per MAX_SSE_CONNS.
Definition sse.cpp:14
SseConn * sse_find(uint8_t slot_id)
Find the SseConn for a given TCP slot, or nullptr.
Definition sse.cpp:43
SseConn * sse_alloc(uint8_t slot_id, const char *path)
Allocate an SseConn and bind it to a TCP slot.
Definition sse.cpp:25
Fully-parsed HTTP/1.1 request.
char method[8]
HTTP method, null-terminated (max 7: OPTIONS).
char path[MAX_PATH_LEN]
URL path, null-terminated; no query string.
Internal route entry stored in the routing table.
RouteType type
HTTP, WS, or SSE.
Handler callback
HTTP handler (ROUTE_HTTP only).
HttpMethod method
HTTP method (ROUTE_HTTP only).
bool is_active
false for unused table slots.
bool is_wildcard
true when path ends with *.
char path[MAX_PATH_LEN]
Null-terminated path pattern.
SSE connection state stored in sse_pool[].
Definition sse.h:46
uint8_t sse_id
Index into sse_pool[] (set at init).
Definition sse.h:47
uint8_t slot_id
Owning TCP slot in conn_pool[].
Definition sse.h:48
A single TCP connection context.
Definition transport.h:69
volatile ConnState state
Lifecycle state; volatile for inter-task visibility.
Definition transport.h:71
struct tcp_pcb * pcb
lwIP PCB; null when slot is free.
Definition transport.h:72
Runtime-tunable server parameters.
WebSocket connection state stored in ws_pool[].
Definition websocket.h:119
uint8_t ws_id
Index into ws_pool[] (set at init).
Definition websocket.h:120
uint8_t slot_id
Owning TCP slot in conn_pool[].
Definition websocket.h:121
WsParseState parse_state
Current frame parser state.
Definition websocket.h:124
TcpConn conn_pool[MAX_CONNS]
Static pool of connection contexts. Defined in transport.cpp.
Definition transport.cpp:25
@ CONN_FREE
Slot is available; no PCB is attached.
Definition transport.h:56
@ CONN_ACTIVE
Live connection; PCB is valid.
Definition transport.h:57
void ws_free(uint8_t slot_id)
Free the WsConn associated with a TCP slot.
Definition websocket.cpp:60
bool ws_send_frame(WsConn *ws, WsOpcode opcode, const uint8_t *payload, uint16_t len)
Send a WebSocket frame to the client.
Definition websocket.cpp:90
void ws_close(WsConn *ws, WsCloseCode code)
Send a Close frame and mark the slot WS_CLOSED.
void ws_parse(WsConn *ws)
Drain the ring buffer for slot_id and feed bytes to the WS parser.
WsConn * ws_find(uint8_t slot_id)
Find the WsConn for a given TCP slot, or nullptr if none.
Definition websocket.cpp:50
void ws_reset_frame(WsConn *ws)
Reset the frame parser back to WS_HEADER1, ready for the next frame.
Definition websocket.cpp:73
WsConn * ws_alloc(uint8_t slot_id)
Allocate a WsConn slot and bind it to a TCP slot.
Definition websocket.cpp:33
WsConn ws_pool[MAX_WS_CONNS]
Pool of WebSocket connection state, one per MAX_WS_CONNS.
Definition websocket.cpp:22
void ws_init()
Initialise all WebSocket pool slots to inactive.
Definition websocket.cpp:24
@ WS_FRAME_READY
Complete frame ready for dispatch.
Definition websocket.h:103
@ WS_ERROR
Protocol error; close frame has been queued.
Definition websocket.h:105
@ WS_CLOSED
Connection closed; slot may be recycled.
Definition websocket.h:104
@ WS_OP_TEXT
UTF-8 text payload.
Definition websocket.h:69
@ WS_OP_BINARY
Binary payload.
Definition websocket.h:70
@ WS_CLOSE_NORMAL
Normal closure.
Definition websocket.h:79