ProtoCore v0.0.1
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
protocore.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 protocore.cpp
6 * @brief Layer 7 (Application) - HTTP routing and request handler implementation.
7 *
8 * **Dispatch pipeline (called from PC::handle())**
9 * ```
10 * handle()
11 * └─ server_tick() ← drain FreeRTOS event queue
12 * └─ for each slot:
13 * ParseState::PARSE_COMPLETE → match_and_execute()
14 * ParseState::PARSE_ERROR → send(400)
15 * ParseState::PARSE_ENTITY_TOO_LARGE → send(413)
16 * ParseState::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 / teardown ownership**
26 * All TCP I/O and teardown go through the transport-layer connection API
27 * (pc_conn_send / pc_conn_flush / pc_conn_begin_close / pc_conn_close /
28 * pc_conn_abort_slot), so this layer never calls lwIP or touches the raw
29 * `tcp_pcb` directly. The transport owns the teardown order for every close:
30 * it detaches the pcb from its lwIP callbacks and sets the slot `ConnState::CONN_FREE`
31 * (pcb nulled) BEFORE the FIN/RST, on the captured pcb pointer. This means any
32 * lwIP error callback that fires mid-teardown sees the slot as already free and
33 * takes no action - preventing a double-free. L7 passes only the slot index:
34 * pc_conn_close(slot) for a graceful local close, pc_conn_abort_slot(slot)
35 * for a hard RST, pc_conn_begin_close(slot) for the drain-then-close dwell.
36 */
37
38#include "protocore.h"
39#include "server/protocore_internal.h" // shared helpers exposed to the src/server/*.cpp handlers
40#include "network_drivers/presentation/presentation.h" // http_proto_set_poll (install the instance-bound HTTP poll)
46#include "shared_primitives/strbuf.h" // pc_sb frame builder
48#if PC_ENABLE_HTTP2
50#endif
51#if PC_ENABLE_HTTP3
53#endif
54#if PC_ENABLE_WEBSOCKET
56#include "crypto/hash/sha1.h"
57#elif PC_ENABLE_AUTH
59#endif
60#if PC_ENABLE_AUTH
61#include "crypto/hash/sha256.h"
62#include "services/system/clock.h" // pc_millis() for the stateless Digest nonce timestamp
63#if PC_ENABLE_HTTP_DELIVERY
64#include "services/file_transfer/http_delivery/http_delivery.h" // pc_delivery_cache_control (SWR directive)
65#endif
66#if PC_ENABLE_AUTH_LOCKOUT
68#endif
69#if PC_ENABLE_FORWARDED_TRUST
71#endif
72#ifdef ARDUINO
73#include <esp_system.h> // esp_random() for the Digest nonce CSPRNG
74#endif
75#endif
76#if PC_ENABLE_CSRF
78#ifdef ARDUINO
79#include <esp_system.h> // esp_random() for the CSRF HMAC secret
80#endif
81#endif
82#if PC_ENABLE_WEBDAV
84#include <time.h> // RFC 1123 Last-Modified formatting
85#endif
86#if PC_ENABLE_METRICS || PC_ENABLE_STATS
87#include "network_drivers/application/web_assets.h" // PC_METRICS_PROM / PC_STATS_JSON (generated)
88#endif
89#if PC_HTTP_EMIT_DATE
90#if PC_ENABLE_TIME_SOURCE
91#include "services/timing_position/time_source/time_source.h" // pc_time_http_date() - any NTP/GPS/RTC/... source
92#else
93#include "services/timing_position/ntp_service/ntp_service.h" // pc_ntp_http_date() - direct NTP (or the host test seam)
94#endif
95#endif
96#include <stdarg.h>
97#include <stdio.h>
98#include <string.h>
99
100// The outbound-transfer continuation types (FileSend/ChunkSend/SendCtx) live in
101// server/protocore_internal.h so the split file_serving / chunked handler TUs share the same
102// per-slot state. This is the single owning definition of that state (external linkage, but the
103// sole definition - the one named owner).
105
106/**
107 * @brief Convert an HTTP status code to its standard reason phrase.
108 *
109 * Covers the 18 codes that arise in typical REST micro-server usage.
110 * Unknown codes produce "Unknown" so callers never receive a null pointer.
111 *
112 * @param code HTTP status integer.
113 * @return Pointer to a string-literal reason phrase; never null.
114 */
115// Response bytes go out via the transport-layer connection I/O API
116// (pc_conn_send / pc_conn_flush / pc_conn_close, see tcp.h) so this
117// application layer never calls lwIP directly.
118
119const char *status_text(int code)
120{
121 switch (code)
122 {
123 case 200:
124 return "OK";
125 case 201:
126 return "Created";
127 case 204:
128 return "No Content";
129 case 206:
130 return "Partial Content";
131#if PC_ENABLE_WEBDAV
132 case 207:
133 return "Multi-Status";
134#endif
135 case 301:
136 return "Moved Permanently";
137 case 302:
138 return "Found";
139 case 303:
140 return "See Other";
141 case 304:
142 return "Not Modified";
143 case 307:
144 return "Temporary Redirect";
145 case 308:
146 return "Permanent Redirect";
147 case 400:
148 return "Bad Request";
149 case 401:
150 return "Unauthorized";
151 case 403:
152 return "Forbidden";
153 case 404:
154 return "Not Found";
155 case 405:
156 return "Method Not Allowed";
157 case 408:
158 return "Request Timeout";
159 case 409:
160 return "Conflict";
161#if PC_ENABLE_WEBDAV
162 case 412:
163 return "Precondition Failed";
164 case 423:
165 return "Locked";
166 case 502:
167 return "Bad Gateway";
168#endif
169 case 413:
170 return "Payload Too Large";
171 case 414:
172 return "URI Too Long";
173 case 416:
174 return "Range Not Satisfiable";
175 case 429:
176 return "Too Many Requests";
177 case 500:
178 return "Internal Server Error";
179 case 501:
180 return "Not Implemented";
181 case 503:
182 return "Service Unavailable";
183 default:
184 return "Unknown";
185 }
186}
187
188/**
189 * @brief Map a method string (from the parsed request line) to an HttpMethod enum.
190 *
191 * Returns HttpVersion::HTTP_UNKNOWN for any method the server does not implement, so the
192 * dispatcher can answer 501 Not Implemented (RFC 7231 §6.5.2) instead of
193 * silently treating it as GET.
194 *
195 * @param m Null-terminated method string, e.g. "POST".
196 * @return Matching HttpMethod enum value, or HttpVersion::HTTP_UNKNOWN.
197 */
198static HttpMethod parse_method(const char *m)
199{
200 if (strcmp(m, "GET") == 0)
201 {
203 }
204 if (strcmp(m, "POST") == 0)
205 {
207 }
208 if (strcmp(m, "PUT") == 0)
209 {
211 }
212 if (strcmp(m, "DELETE") == 0)
213 {
215 }
216 if (strcmp(m, "PATCH") == 0)
217 {
219 }
220 if (strcmp(m, "HEAD") == 0)
221 {
223 }
224 if (strcmp(m, "OPTIONS") == 0)
225 {
227 }
229}
230
231/**
232 * @brief Canonical method token for an HttpMethod (for the Allow header).
233 */
234static const char *method_name(HttpMethod m)
235{
236 switch (m)
237 {
239 return "GET";
241 return "POST";
243 return "PUT";
245 return "DELETE";
247 return "PATCH";
249 return "HEAD";
251 return "OPTIONS";
252 default:
253 return "";
254 }
255}
256
258 : _route_count(0), _not_found_handler(nullptr), _cors_enabled(false), _log_cb(nullptr), _listener_count(0),
259 _middleware_count(0), _rl_max(0), _rl_window_ms(0), _rl_window_start(0), _rl_count(0)
260{
261 for (int i = 0; i < MAX_ROUTES; i++)
262 {
263 _routes[i] = {};
264 }
265 for (int i = 0; i < MAX_MIDDLEWARE; i++)
266 {
267 _middleware[i] = nullptr;
268 }
269 _cors_header_buf[0] = '\0';
270 _cache_control_buf[0] = '\0';
271 for (int i = 0; i < MAX_CONNS; i++)
272 {
273 _extra_hdr[i][0] = '\0';
274 }
275#if PC_ENABLE_AUTH
276 regen_digest_secret();
277#endif
278#if PC_ENABLE_STATS
279 _stat_requests = 0;
280 _stat_2xx = 0;
281 _stat_4xx = 0;
282 _stat_5xx = 0;
283#endif
284}
285
287{
288 _log_cb = cb;
289}
290
291// Record a completed response: bump stats counters and fire the access-log hook.
292// The request's method/path are still intact in http_pool[slot_id] (http_reset
293// has not run yet at the call sites).
294void PC::note_response(uint8_t slot_id, int code, int body_len)
295{
296#if PC_ENABLE_STATS
297 _stat_requests++;
298 if (code >= 500)
299 {
300 _stat_5xx++;
301 }
302 else if (code >= 400)
303 {
304 _stat_4xx++;
305 }
306 else if (code >= 200 && code < 300)
307 {
308 _stat_2xx++;
309 }
310#endif
311 if (_log_cb)
312 {
313 const HttpReq *r = &http_pool[slot_id];
314 _log_cb(r->method, r->path, code, body_len);
315 }
316}
317
318#if PC_ENABLE_KEEPALIVE || PC_ENABLE_WEBSOCKET
319// Case-insensitive search for @p token as a comma/space-delimited element of a
320// Connection header value (e.g. "keep-alive" in "Keep-Alive, Upgrade"). Shared by
321// keep-alive evaluation and the WebSocket Upgrade-token check.
322static bool conn_has_token(const char *hdr, const char *token)
323{
324 if (!hdr)
325 {
326 return false;
327 }
328 size_t tlen = strnlen(token, 32);
329 const char *p = hdr;
330 while (*p)
331 {
332 while (*p == ' ' || *p == ',' || *p == '\t')
333 {
334 p++;
335 }
336 const char *start = p;
337 while (*p && *p != ',')
338 {
339 p++;
340 }
341 size_t len = (size_t)(p - start);
342 while (len && (start[len - 1] == ' ' || start[len - 1] == '\t'))
343 {
344 len--;
345 }
346 if (len == tlen && strncasecmp(start, token, tlen) == 0)
347 {
348 return true;
349 }
350 if (*p == ',')
351 {
352 p++;
353 }
354 }
355 return false;
356}
357#endif // PC_ENABLE_KEEPALIVE || PC_ENABLE_WEBSOCKET
358
359#if PC_ENABLE_KEEPALIVE
360bool PC::keepalive_eval(uint8_t slot_id)
361{
362 HttpReq *req = &http_pool[slot_id];
363 // Only a cleanly-parsed request has a known message boundary; errors close.
365 {
366 return false;
367 }
368
369 const char *c = http_get_header(req, "Connection");
370 bool keep;
371 if (req->version == HttpVersion::HTTP_11)
372 {
373 keep = !conn_has_token(c, "close"); // 1.1 default: persistent
374 }
375 else
376 {
377 keep = conn_has_token(c, "keep-alive"); // 1.0/unknown default: close
378 }
379 if (!keep)
380 {
381 return false;
382 }
383
384 // Fairness bound: serve at most PC_KEEPALIVE_MAX_REQUESTS, then close.
385 if (++http_req_count[slot_id] >= PC_KEEPALIVE_MAX_REQUESTS)
386 {
387 return false;
388 }
389 return true;
390}
391#endif // PC_ENABLE_KEEPALIVE
392
393// Finish a response: flush, then either begin the graceful ConnState::CONN_CLOSING dwell
394// (close path) or leave the slot active for reuse (keep-alive). The HTTP parser
395// is reset either way, returning a kept-alive slot to ParseState::PARSE_METHOD ready for the
396// next request. The slot stays ConnState::CONN_ACTIVE through the write for BOTH paths
397// (callbacks live - the model keep-alive has always used); the close path then
398// dwells in ConnState::CONN_CLOSING from here, so the slot is reclaimed only once the peer
399// ACKs the response (or the CLOSING timeout fires), not before it is delivered.
400// Every response path now addresses the connection by slot alone - the transport
401// resolves the pcb internally, the same way the RX read path does (no pcb is
402// threaded through the app layer, so the send target can never disagree).
403void PC::pc_resp_end(uint8_t slot_id, int code, int body_len, bool keep, bool pre_flushed)
404{
405 if (!pre_flushed)
406 {
407 pc_conn_flush(slot_id); // a pre_flushed caller already did tcp_output in its final send
408 }
409 if (!keep)
410 {
411 pc_conn_begin_close(slot_id); // ACTIVE -> ConnState::CONN_CLOSING; finalizes on ACK
412 }
413 note_response(slot_id, code, body_len);
414 http_reset(slot_id);
415}
416
417// Resolve the Connection response header (and report keep-alive intent) in one
418// place so every response path agrees. Keep-alive compiled out always closes.
419const char *PC::pc_resp_conn_hdr(uint8_t slot_id, bool *keep_out)
420{
421 bool keep = false;
422#if PC_ENABLE_KEEPALIVE
423 keep = keepalive_eval(slot_id);
424#else
425 (void)slot_id;
426#endif
427 // The null half cannot fire: every call site passes the address of its own local `keep`. Kept so
428 // the signature keeps saying the report-back is optional.
429 if (keep_out) // GCOVR_EXCL_BR_LINE keep_out is never null (see above)
430 {
431 *keep_out = keep;
432 }
433 return keep ? "Connection: keep-alive\r\n" : "Connection: close\r\n";
434}
435
436// Append the shared response trailer (CORS block + custom headers + Connection +
437// the terminating blank line) to a header buffer already holding the status line
438// and per-response headers. One owner for the trailer every dynamic response ends
439// with. Returns the new total length.
440const char PC_RESP_HDR_OVERFLOW[] = "HTTP/1.1 500 Internal Server Error\r\n"
441 "Content-Length: 0\r\n"
442 "Connection: close\r\n\r\n";
443// Taken with sizeof at the definition, where the array bound is still visible. The send site sees
444// only `extern const char[]`, so measuring it there would mean scanning a string whose length was
445// known when it was written.
447
448int PC::append_resp_trailer(char *buf, size_t cap, int hlen, uint8_t slot_id, const char *cl)
449{
450 // hlen is the caller's status-line length from pc_sb_finish, which reports 0 for a status line
451 // that did not fit. Appending the trailer at offset 0 in that case would emit a response with
452 // no status line at all, so 0 propagates as failure and the caller sends a canned reply.
453 //
454 // The old code clamped an over-long header to cap-1 and sent it. That is worse than sending
455 // nothing: a header block cut mid-field has no terminating CRLF, so the peer reads the body as
456 // continued headers and the connection desynchronizes. A response either fits or is refused.
457 if (hlen <= 0)
458 {
459 return 0;
460 }
461 if ((size_t)hlen >= cap)
462 {
463 return 0;
464 }
465#if PC_HTTP_EMIT_DATE
466 // RFC 7231 7.1.1.2: emit Date only when a real wall-clock time exists; a clock-less device (no
467 // synced/valid time source yet) omits it. The time comes from the multi-source registry (any
468 // enabled NTP / GPS / RTC / ... by priority) when PC_ENABLE_TIME_SOURCE is set, else straight
469 // from NTP.
470 char date_hdr[48] = "";
471 char imf[40];
472#if PC_ENABLE_TIME_SOURCE
473 if (pc_time_http_date(imf, sizeof(imf)) > 0)
474#else
475 if (pc_ntp_http_date(imf, sizeof(imf)) > 0)
476#endif
477 {
478 pc_sb sb_date_hdr = {date_hdr, sizeof(date_hdr), 0, true};
479 pc_sb_put(&sb_date_hdr, "Date: ");
480 pc_sb_put(&sb_date_hdr, imf);
481 pc_sb_put(&sb_date_hdr, "\r\n");
482 if (pc_sb_finish(&sb_date_hdr) == 0)
483 {
484 date_hdr[0] = '\0';
485 }
486 }
487#else
488 const char *date_hdr = "";
489#endif
490 pc_sb sb411 = {buf + hlen, cap - (size_t)hlen, 0, true};
491 pc_sb_put(&sb411, date_hdr);
492 pc_sb_put(&sb411, _cors_enabled ? _cors_header_buf : "");
493 pc_sb_put(&sb411, _extra_hdr[slot_id]);
494 pc_sb_put(&sb411, cl);
495 pc_sb_put(&sb411, "\r\n");
496 int n = (int)pc_sb_finish(&sb411);
497 if (!sb411.ok)
498 {
499 return 0; // trailer does not fit: refuse the response rather than send a headless one
500 }
501 return hlen + n;
502}
503
504int32_t PC::listen(uint16_t port, ConnProto proto)
505{
506 if (_listener_count >= MAX_LISTENERS)
507 {
508 return (int32_t)pc_result::PC_ERR_LISTENER_FULL;
509 }
510 _listen_ports[_listener_count] = port;
511 _listen_protos[_listener_count] = proto;
512 _listen_tls[_listener_count] = false;
513 _listener_count++;
514 // Return the listener id (its index), not pc_result::PC_OK: begin() binds listener_pool[i] from
515 // _listen_ports[i] and the accept path stamps that same index onto the slot, so this id is what
516 // pc_relay_publish() / pc_ssh_forward_begin() must match against. (Errors are negative.)
517 return (int32_t)(_listener_count - 1);
518}
519
520// Server instance bindings, owned by one instance (internal linkage): the instance whose
521// pipeline the worker task pumps, the HTTP/3-running flag, and the instance the HTTP on_poll
522// forwarder dispatches into. The library serves from a single PC (the slot pools are
523// global singletons), which is exactly what these instance pointers model. One named owner,
524// unreachable from any other translation unit. Set in begin().
526{
527 PC *worker_server = nullptr;
528#if PC_ENABLE_HTTP3
529 bool pc_h3_running = false;
530#endif
531 PC *http_instance = nullptr;
532};
533static InstanceCtx s_inst;
534#ifdef ARDUINO
535// The worker task's per-tick entry (registered with pc_workers_start below); ESP32-only, so it is
536// compiled only where it is used - on host the pipeline runs inline via handle().
537static void pc_pump_trampoline(int worker_id)
538{
539 if (s_inst.worker_server)
540 {
541 s_inst.worker_server->service_once(worker_id);
542 }
543}
544#endif
545
546#if PC_ENABLE_HTTP3
547// The pc_quic_server request seam has no PC type; this trampoline forwards a completed
548// HTTP/3 request into the instance's shared route dispatcher (app == the PC *).
549static void pc_h3_request_trampoline(void *app, uint32_t conn_id, uint64_t stream_id, const char *method,
550 const char *path, const char *authority, const uint8_t *body, size_t body_len)
551{
552 if (app) // GCOVR_EXCL_BR_LINE app is always the non-null `this` passed to pc_quic_server_begin(); this static
553 // trampoline has no other caller, so app==null is unreachable
554 {
555 ((PC *)app)->dispatch_h3_request(conn_id, stream_id, method, path, authority, body, body_len);
556 }
557}
558
559// Randomness for the QUIC ephemeral X25519 key, the ServerHello random, and our connection IDs: the
560// hardware TRNG on device; a deterministic PRNG on host (test builds carry no security context and
561// have no esp_random).
562static void pc_h3_rng(uint8_t *out, size_t len)
563{
564#ifdef ARDUINO
565 size_t i = 0;
566 while (i < len)
567 {
568 uint32_t r = esp_random();
569 size_t n = (len - i) < 4 ? (len - i) : 4;
570 memcpy(out + i, &r, n);
571 i += n;
572 }
573#else
574 static uint32_t s = 0x9e3779b9u;
575 for (size_t i = 0; i < len; i++)
576 {
577 s = s * 1664525u + 1013904223u;
578 out[i] = (uint8_t)(s >> 24);
579 }
580#endif
581}
582#endif // PC_ENABLE_HTTP3
583
584// HTTP's poll is instance-bound (it dispatches into this server's routes), so it cannot be a plain
585// global on_poll like the singleton protocols. begin() records the serving instance here and installs
586// this forwarder as the HTTP ProtoHandler's on_poll, so the worker loop pumps HTTP through the one
587// uniform seam. The library serves from a single PC (the slot pools are global singletons),
588// which is exactly what this one instance pointer models.
589static void pc_http_on_poll(uint8_t slot)
590{
591 // GCOVR_EXCL_BR_START the null half cannot fire: service_once() is the only thing that installs
592 // this forwarder (http_proto_set_poll) and it stores http_instance on the line before, so the
593 // pointer always exists by the time a poll can reach here.
594 if (s_inst.http_instance)
595 {
596 s_inst.http_instance->http_poll_slot(slot);
597 }
598 // GCOVR_EXCL_BR_STOP
599}
600
601int32_t PC::begin(const WebServerConfig *cfg)
602{
603 if (_listener_count == 0
605 && !_h3_enabled // an HTTP/3-only server binds UDP, not a TCP listener
606#endif
607 )
608 return (int32_t)pc_result::PC_ERR_NO_LISTENERS;
610#if PC_ENABLE_AUTH
611 regen_digest_secret(); // fresh server keying secret per begin()
612#endif
613#if PC_ENABLE_CSRF
614 {
615 // Seed the CSRF HMAC secret from the hardware RNG (a fixed dev secret on
616 // native/test builds, which have no esp_random).
617 uint8_t sec[32];
618#ifdef ARDUINO
619 for (int i = 0; i < 8; i++)
620 {
621 uint32_t r = esp_random();
622 memcpy(sec + i * 4, &r, 4);
623 }
624#else
625 for (int i = 0; i < 32; i++)
626 {
627 sec[i] = (uint8_t)(0xA5 ^ i);
628 }
629#endif
630 pc_csrf_set_secret(sec, sizeof(sec));
631 }
632#endif
633 for (uint8_t i = 0; i < MAX_CONNS; i++)
634 {
635 http_reset(i);
636 }
637#if PC_ENABLE_WEBSOCKET
638 ws_init();
639#endif
640#if PC_ENABLE_SSE
641 pc_sse_init();
642#endif
643 for (uint8_t i = 0; i < _listener_count; i++)
644 {
645 // GCOVR_EXCL_START listener_add() only fails on a real lwIP fault - a bind/listen refusal
646 // (port already in use) or PCB/queue exhaustion. The host lwIP mock's tcp_new/tcp_bind/
647 // tcp_listen_with_backlog always succeed and it exposes no failure hook, so the error return
648 // has no host path; it is covered on hardware.
649 if (listener_add(i, _listen_ports[i], _listen_protos[i], _listen_tls[i]) < 0)
650 {
651 return (int32_t)pc_result::PC_ERR_LISTEN_FAILED;
652 }
653 // GCOVR_EXCL_STOP
654 }
655#if PC_ENABLE_HTTP3
656 // Bind the HTTP/3 QUIC server (UDP on device; on host it is fed via pc_quic_server_ingest). Requests
657 // dispatch through this instance's routes via the trampoline; pc_quic_server_poll() runs in service_once.
658 if (_h3_enabled)
659 {
660 QuicServerConfig h3cfg;
661 memset(&h3cfg, 0, sizeof(h3cfg));
662 h3cfg.cert_der = _h3_cert;
663 h3cfg.cert_len = _h3_cert_len;
664 memcpy(h3cfg.ed25519_seed, _h3_seed, sizeof(h3cfg.ed25519_seed));
665 h3cfg.rng = pc_h3_rng;
666 s_inst.pc_h3_running = pc_quic_server_begin(_h3_port, &h3cfg, pc_h3_request_trampoline, this);
667 }
668#endif
669#ifdef ARDUINO
670 // Routes/listeners are now fixed; start the worker task(s) that drive the
671 // pipeline off the user's loop(). On host the pipeline runs inline via handle().
672 s_inst.worker_server = this;
673 pc_workers_start(pc_pump_trampoline);
674#endif
675 return (int32_t)pc_result::PC_OK;
676}
677
678int32_t PC::begin(uint16_t port, const WebServerConfig *cfg)
679{
680 int32_t rc = listen(port);
681 if (rc < 0)
682 {
683 return rc;
684 }
685 return begin(cfg);
686}
687
688#if PC_ENABLE_HTTP3
689bool PC::pc_h3_cert(const uint8_t *cert_der, size_t cert_len, const uint8_t ed25519_seed[32], uint16_t port)
690{
691 if (!cert_der || cert_len == 0 || !ed25519_seed)
692 {
693 return false;
694 }
695 _h3_cert = cert_der;
696 _h3_cert_len = cert_len;
697 memcpy(_h3_seed, ed25519_seed, sizeof(_h3_seed));
698 _h3_port = port;
699 _h3_enabled = true;
700 return true;
701}
702
703// Response sink for the HTTP/3 dispatch slot: route (code, content_type, body) onto the QUIC stream
704// the request arrived on (ids stashed on the slot by dispatch_h3_request). Installed as conn->pc_resp_sink
705// so send()/send_empty() stay protocol-agnostic.
706static bool pc_h3_resp_sink(uint8_t slot, int code, const char *content_type, const char *body, size_t len)
707{
708 TcpConn *c = &conn_pool[slot];
709 return pc_quic_server_respond(c->pc_h3_conn_id, c->pc_h3_stream, code, content_type, (const uint8_t *)body, len);
710}
711
712void PC::dispatch_h3_request(uint32_t conn_id, uint64_t stream_id, const char *method, const char *path,
713 const char *authority, const uint8_t *body, size_t body_len)
714{
715 const uint8_t slot = PC_H3_DISPATCH_SLOT;
716 HttpReq *r = &http_pool[slot];
717 http_reset(slot);
718
719 // Map the semantic request fields into the shared HttpReq (as pc_h2_server does per stream).
720 size_t mn = strnlen(method, sizeof(r->method));
721 if (mn >= sizeof(r->method))
722 {
723 mn = sizeof(r->method) - 1;
724 }
725 memcpy(r->method, method, mn);
726 r->method[mn] = 0;
727
728 const char *q = strchr(path, '?');
729 size_t plen = q ? (size_t)(q - path) : strnlen(path, sizeof(r->path));
730 if (plen >= sizeof(r->path))
731 {
732 plen = sizeof(r->path) - 1;
733 }
734 memcpy(r->path, path, plen);
735 r->path[plen] = 0;
736 r->path_idx = strnlen(r->path, sizeof(r->path));
737 if (q)
738 {
739 size_t ql = strnlen(q + 1, sizeof(r->query));
740 if (ql >= sizeof(r->query))
741 {
742 ql = sizeof(r->query) - 1;
743 }
744 memcpy(r->query, q + 1, ql);
745 r->query[ql] = 0;
746 r->query_idx = strnlen(r->query, sizeof(r->query));
747 }
748
749 // :authority maps to Host, the way the h2 bridge does.
750 if (authority && authority[0] && r->header_count < MAX_HEADERS)
751 {
752 Header *h = &r->headers[r->header_count++];
753 memcpy(h->key, "host", 5);
754 size_t vl = strnlen(authority, sizeof(h->val));
755 if (vl >= sizeof(h->val))
756 {
757 vl = sizeof(h->val) - 1;
758 }
759 memcpy(h->val, authority, vl);
760 h->val[vl] = 0;
761 }
762
763 if (body && body_len)
764 {
765 size_t n = body_len > BODY_BUF_SIZE ? BODY_BUF_SIZE : body_len;
766 memcpy(r->body, body, n);
767 r->body_len = n;
768 r->body[r->body_len] = 0;
769 r->body_bytes_read = body_len;
770 r->content_length = body_len;
771 }
773
774 // Mark the reserved slot as HTTP/3 and install the response sink so send() / send_empty() route the
775 // response back onto this stream (no TCP pcb here - the sink owns the QUIC framing).
776 TcpConn *c = &conn_pool[slot];
777 c->h3 = 1;
778 c->pc_h3_conn_id = conn_id;
779 c->pc_h3_stream = stream_id;
780 c->pc_resp_sink = pc_h3_resp_sink;
782 pc_conn_set_state(slot, ConnState::CONN_ACTIVE); // reserved slot: no bitmask bit (slot >= MAX_CONNS)
783 c->pcb = nullptr;
784
785 match_and_execute(slot); // -> handler -> send() -> pc_resp_sink -> pc_quic_server_respond()
786
787 // Release the dispatch slot for the next request (a no-response handler simply leaves the stream open).
788 c->h3 = 0;
789 c->pc_resp_sink = nullptr;
790 pc_conn_set_state(slot, ConnState::CONN_FREE); // reserved slot: no bitmask bit (slot >= MAX_CONNS)
791 http_reset(slot);
792}
793#endif // PC_ENABLE_HTTP3
794
795#if PC_ENABLE_TLS
796bool PC::tls_cert(const uint8_t *cert, size_t cert_len, const uint8_t *key, size_t key_len)
797{
798 return pc_tls_global_init(cert, cert_len, key, key_len);
799}
800
801int32_t PC::listen_tls(uint16_t port)
802{
803 if (_listener_count >= MAX_LISTENERS)
804 {
805 return (int32_t)pc_result::PC_ERR_LISTENER_FULL;
806 }
807 _listen_ports[_listener_count] = port;
808 _listen_protos[_listener_count] = ConnProto::PROTO_HTTP;
809 _listen_tls[_listener_count] = true;
810 _listener_count++;
811 return (int32_t)pc_result::PC_OK;
812}
813
814int32_t PC::begin_tls(uint16_t port, const uint8_t *cert, size_t cert_len, const uint8_t *key, size_t key_len,
815 const WebServerConfig *cfg)
816{
817 if (!tls_cert(cert, cert_len, key, key_len))
818 {
819 return (int32_t)pc_result::PC_ERR_LISTEN_FAILED;
820 }
821 int32_t rc = listen_tls(port);
822 if (rc < 0)
823 {
824 return rc;
825 }
826 return begin(cfg);
827}
828
829#if PC_ENABLE_MTLS
830bool PC::tls_require_client_cert(const uint8_t *ca, size_t ca_len)
831{
832 return pc_tls_set_client_ca(ca, ca_len);
833}
834
835int PC::tls_client_subject(uint8_t slot_id, char *out, size_t out_len)
836{
837 return pc_tls_peer_subject(slot_id, out, out_len);
838}
839#endif // PC_ENABLE_MTLS
840#endif // PC_ENABLE_TLS
841
842int32_t PC::restart(const WebServerConfig *cfg)
843{
844 if (_listener_count == 0)
845 {
846 return (int32_t)pc_result::PC_ERR_NO_LISTENERS;
847 }
848 stop();
849 return begin(cfg);
850}
851
853{
854#ifdef ARDUINO
855 // Stop the worker task(s) before tearing down the slots they service.
857#endif
860 for (uint8_t i = 0; i < MAX_CONNS; i++)
861 {
862 http_reset(i);
863 }
864#if PC_ENABLE_WEBSOCKET
865 ws_init();
866#endif
867#if PC_ENABLE_SSE
868 pc_sse_init();
869#endif
870}
871
872/**
873 * @brief Register a route in the route table.
874 *
875 * Paths are stored null-terminated and truncated to MAX_PATH_LEN. The
876 * trailing character of the stored path is inspected to detect wildcard
877 * routes: any path ending in `*` is treated as a prefix match.
878 *
879 * Registrations beyond MAX_ROUTES are silently ignored - callers should
880 * verify return values if overflow is a concern.
881 *
882 * @param path URL path to match, e.g. "/api/*".
883 * @param method HTTP method that triggers this route.
884 * @param callback Handler invoked with (slot_id, request).
885 */
886void fill_route_base(Route *r, const char *path)
887{
888 strncpy(r->path, path, MAX_PATH_LEN - 1);
889 r->path[MAX_PATH_LEN - 1] = '\0';
890 r->is_active = true;
891 size_t len = strnlen(r->path, MAX_PATH_LEN);
892 r->is_wildcard = (len > 0 && r->path[len - 1] == '*');
893 r->is_param = (strstr(r->path, "/:") != nullptr);
894 r->is_regex = false;
896}
897
898void PC::on(const char *path, HttpMethod method, Handler callback)
899{
900 if (_route_count >= MAX_ROUTES)
901 {
902 return;
903 }
904 Route *r = &_routes[_route_count++];
905 fill_route_base(r, path);
907 r->method = method;
908 r->callback = callback;
909}
910
911void PC::on(const char *path, HttpMethod method, Handler callback, pc_iface iface)
912{
913 if (_route_count >= MAX_ROUTES)
914 {
915 return;
916 }
917 Route *r = &_routes[_route_count++];
918 fill_route_base(r, path);
920 r->method = method;
921 r->callback = callback;
922 r->iface_filter = iface;
923}
924
925void PC::set_ap_ip(uint32_t ap_ip)
926{
927 pc_ap_ip = ap_ip;
928}
929
930void PC::on_regex(const char *pattern, HttpMethod method, Handler callback)
931{
932 if (_route_count >= MAX_ROUTES)
933 {
934 return;
935 }
936 Route *r = &_routes[_route_count++];
937 fill_route_base(r, pattern);
939 r->method = method;
940 r->callback = callback;
941 r->is_regex = true;
942}
943
944#if PC_ENABLE_AUTH
945void PC::on(const char *path, HttpMethod method, Handler callback, const char *realm, const char *user,
946 const char *pass, bool digest)
947{
948 if (_route_count >= MAX_ROUTES)
949 {
950 return;
951 }
952 Route *r = &_routes[_route_count++];
953 fill_route_base(r, path);
955 r->method = method;
956 r->callback = callback;
957 r->auth_required = true;
958 r->auth_digest = digest;
959 strncpy(r->auth_realm, realm, MAX_AUTH_LEN - 1);
960 r->auth_realm[MAX_AUTH_LEN - 1] = '\0';
961 strncpy(r->auth_user, user, MAX_AUTH_LEN - 1);
962 r->auth_user[MAX_AUTH_LEN - 1] = '\0';
963 strncpy(r->auth_pass, pass, MAX_AUTH_LEN - 1);
964 r->auth_pass[MAX_AUTH_LEN - 1] = '\0';
965}
966#endif // PC_ENABLE_AUTH
967
968#if PC_ENABLE_WEBSOCKET
969void PC::on_ws(const char *path, WsConnectHandler on_connect, WsMessageHandler on_message, WsCloseHandler on_close)
970{
971 if (_route_count >= MAX_ROUTES)
972 {
973 return;
974 }
975 Route *r = &_routes[_route_count++];
976 fill_route_base(r, path);
977 r->type = RouteType::ROUTE_WS;
978 r->ws_connect = on_connect;
979 r->ws_message = on_message;
980 r->ws_close = on_close;
981}
982#endif // PC_ENABLE_WEBSOCKET
983
984#if PC_ENABLE_SSE
985void PC::on_sse(const char *path, SseConnectHandler on_connect)
986{
987 if (_route_count >= MAX_ROUTES)
988 {
989 return;
990 }
991 Route *r = &_routes[_route_count++];
992 fill_route_base(r, path);
993 r->type = RouteType::ROUTE_SSE;
994 r->pc_sse_connect = on_connect;
995}
996#endif // PC_ENABLE_SSE
997
999{
1000 _not_found_handler = callback;
1001}
1002
1003/*
1004 * Enable CORS and pre-build the Access-Control response header block.
1005 *
1006 * The header string is constructed once here rather than at response time to
1007 * avoid repeated snprintf calls on the hot path. It is stored in
1008 * `_cors_header_buf[]` and injected verbatim into every response when
1009 * `_cors_enabled` is true.
1010 *
1011 * Passing an empty or null origin disables CORS without clearing the buffer -
1012 * only the `_cors_enabled` flag matters at dispatch time.
1013 *
1014 * @param origin Value for the Access-Control-Allow-Origin header, e.g. "*".
1015 */
1016void PC::set_cors(const char *origin)
1017{
1018 if (!origin || origin[0] == '\0')
1019 {
1020 _cors_enabled = false;
1021 _cors_header_buf[0] = '\0';
1022 return;
1023 }
1024 pc_sb sb__cors_header_buf = {_cors_header_buf, CORS_HDR_BUF_SIZE, 0, true};
1025 pc_sb_put(&sb__cors_header_buf, "Access-Control-Allow-Origin: ");
1026 pc_sb_put(&sb__cors_header_buf, origin);
1027 pc_sb_put(&sb__cors_header_buf, "\r\nAccess-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, HEAD, "
1028 "OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\n");
1029 if (pc_sb_finish(&sb__cors_header_buf) == 0)
1030 {
1031 _cors_header_buf[0] = '\0';
1032 }
1033 _cors_enabled = true;
1034}
1035
1036void PC::set_cache_control(const char *value)
1037{
1038 if (!value || value[0] == '\0')
1039 {
1040 _cache_control_buf[0] = '\0';
1041 return;
1042 }
1043 pc_sb sb__cache_control_buf = {_cache_control_buf, CACHE_CONTROL_BUF_SIZE, 0, true};
1044 pc_sb_put(&sb__cache_control_buf, "Cache-Control: ");
1045 pc_sb_put(&sb__cache_control_buf, value);
1046 pc_sb_put(&sb__cache_control_buf, "\r\n");
1047 if (pc_sb_finish(&sb__cache_control_buf) == 0)
1048 {
1049 _cache_control_buf[0] = '\0';
1050 }
1051}
1052
1053#if PC_ENABLE_HTTP_DELIVERY
1054bool PC::set_cache_control_swr(uint32_t max_age_s, uint32_t swr_s)
1055{
1056 // Build the directive with the RFC 5861 core so the header and the pc_delivery_swr decision
1057 // can never drift apart.
1058 char directive[64];
1059 if (pc_delivery_cache_control(max_age_s, swr_s, directive, sizeof(directive)) == 0)
1060 {
1061 return false;
1062 }
1063 set_cache_control(directive);
1064 return true;
1065}
1066#endif
1067
1068/**
1069 * @brief Test whether a route path matches an incoming request path.
1070 *
1071 * Exact routes use strcmp (full-string equality). Wildcard routes use
1072 * strncmp against the prefix up to (but not including) the trailing `*`.
1073 *
1074 * @param route Registered route path, potentially ending in `*`.
1075 * @param is_wildcard True when the route was registered with a trailing `*`.
1076 * @param req_path Incoming request path from the parsed HTTP request line.
1077 * @return True if the route matches the request path.
1078 */
1079bool PC::path_matches(const char *route, bool is_wildcard, const char *req_path)
1080{
1081 if (!is_wildcard)
1082 {
1083 return strcmp(route, req_path) == 0;
1084 }
1085
1086 // Prefix match: compare everything up to (but not including) the '*'
1087 size_t prefix_len = strnlen(route, MAX_PATH_LEN) - 1;
1088 return strncmp(route, req_path, prefix_len) == 0;
1089}
1090
1091/**
1092 * @brief Segment-by-segment match for routes containing `:name` parameters.
1093 *
1094 * Walks @p route and @p path one `/`-delimited segment at a time. Literal
1095 * segments must match exactly; a `:name` segment captures the corresponding
1096 * path segment into @p req->path_params. Both must contain the same number of
1097 * segments. No wildcard support (`:name` and trailing `*` are not combined).
1098 *
1099 * @return True on a full match (params captured); false otherwise.
1100 */
1101// Record one `:name` path parameter (key from the route segment, value from the path segment), each
1102// truncated to its buffer. No-op once the param table is full. Extracted to keep the matcher loop flat.
1103static void capture_path_param(HttpReq *req, const char *key, size_t klen, const char *val, size_t vlen)
1104{
1106 {
1107 return;
1108 }
1109 QueryParam *qp = &req->path_params[req->path_param_count++];
1110 if (klen > QUERY_KEY_LEN - 1)
1111 {
1112 klen = QUERY_KEY_LEN - 1;
1113 }
1114 memcpy(qp->key, key, klen);
1115 qp->key[klen] = '\0';
1116 if (vlen > QUERY_VAL_LEN - 1)
1117 {
1118 vlen = QUERY_VAL_LEN - 1;
1119 }
1120 memcpy(qp->val, val, vlen);
1121 qp->val[vlen] = '\0';
1122}
1123
1124static bool match_path_params(const char *route, const char *path, HttpReq *req)
1125{
1126 req->path_param_count = 0;
1127 const char *r = route;
1128 const char *p = path;
1129
1130 while (*r == '/' && *p == '/')
1131 {
1132 r++;
1133 p++;
1134 const char *rseg = r;
1135 while (*r && *r != '/')
1136 {
1137 r++;
1138 }
1139 size_t rlen = (size_t)(r - rseg);
1140 const char *pseg = p;
1141 while (*p && *p != '/')
1142 {
1143 p++;
1144 }
1145 size_t plen = (size_t)(p - pseg);
1146
1147 if (rlen > 0 && rseg[0] == ':')
1148 {
1149 if (plen == 0)
1150 {
1151 return false; // a `:name` segment must capture a non-empty value
1152 }
1153 capture_path_param(req, rseg + 1, rlen - 1, pseg, plen);
1154 }
1155 else if (rlen != plen || strncmp(rseg, pseg, rlen) != 0)
1156 {
1157 return false; // literal segment mismatch
1158 }
1159 }
1160
1161 // Both strings must be fully consumed (identical segment counts).
1162 return (*r == '\0' && *p == '\0');
1163}
1164
1165/**
1166 * @brief Main application tick - tick the session layer then dispatch completed requests.
1167 *
1168 * Call this repeatedly from loop(). Each call:
1169 * 1. Calls server_tick() which runs timeout sweeps + drains the event queue.
1170 * 2. Walks all slots; any in ParseState::PARSE_COMPLETE is dispatched via match_and_execute().
1171 * 3. Any slot left in ParseState::PARSE_COMPLETE after dispatch (i.e., callback did not
1172 * send a response) is reset so it doesn't block the slot.
1173 * 4. Any slot in ParseState::PARSE_ERROR receives an automatic 400 response.
1174 * 5. Any slot in ParseState::PARSE_ENTITY_TOO_LARGE receives an automatic 413 response.
1175 * 6. Any slot in ParseState::PARSE_URI_TOO_LONG receives an automatic 414 response.
1176 */
1177#if PC_ENABLE_WEBSOCKET
1178void PC::ws_dispatch_message(const WsConn *ws) const
1179{
1180 for (uint8_t r = 0; r < _route_count; r++)
1181 {
1182 if (_routes[r].type == RouteType::ROUTE_WS && _routes[r].ws_message)
1183 {
1184 _routes[r].ws_message(ws->ws_id);
1185 break;
1186 }
1187 }
1188}
1189
1190void PC::ws_dispatch_close(const WsConn *ws) const
1191{
1192 for (uint8_t r = 0; r < _route_count; r++)
1193 {
1194 if (_routes[r].type == RouteType::ROUTE_WS && _routes[r].ws_close)
1195 {
1196 _routes[r].ws_close(ws->ws_id);
1197 break;
1198 }
1199 }
1200}
1201#endif // PC_ENABLE_WEBSOCKET
1202
1204{
1205#ifdef ARDUINO
1206 // The worker task drives the pipeline on its own core; loop() is freed.
1207 if (pc_workers_running())
1208 {
1209 return;
1210 }
1211#endif
1212 service_once();
1213}
1214
1215void PC::service_once(int worker_id)
1216{
1217 // Wire HTTP's instance-bound poll to the server currently being serviced, so the dispatch loop
1218 // pumps HTTP through the uniform ProtoHandler::on_poll seam (see http_poll_slot). Done here (not
1219 // just begin()) so test paths that drive service_once() directly also install it, and so it always
1220 // targets the running instance. Two pointer stores; negligible at poll cadence.
1221 s_inst.http_instance = this;
1222 http_proto_set_poll(pc_http_on_poll);
1223
1224 server_tick(worker_id);
1225
1226#if PC_ENABLE_HTTP3
1227 // Drive the QUIC/HTTP-3 server: ingest queued datagrams, run the engines (which dispatch requests
1228 // through this instance's routes), flush replies. One worker owns it, so requests stay single-threaded.
1229 if (worker_id == 0 && s_inst.pc_h3_running)
1230 {
1231 pc_quic_server_poll(pc_millis());
1232 }
1233#endif
1234
1235 for (uint8_t i = 0; i < MAX_CONNS; i++)
1236 {
1237 // This worker services only the slots it owns (all of them at N=1).
1238 if (conn_pool[i].owner != worker_id)
1239 {
1240 continue;
1241 }
1242
1243 // Ack-on-consume: reopen the TCP receive window by whatever any consumer
1244 // (HTTP/WS/TLS/service) drained from this slot's ring on the previous pass.
1245 // Transport owns the window math; we just nudge it once per slot per loop.
1247
1248 // Every protocol - HTTP included - is pumped through the one uniform ProtoHandler::on_poll
1249 // seam (no per-protocol branch here). HTTP's poll is instance-bound (it dispatches into this
1250 // server's routes), installed at begin() via http_proto_set_poll() -> http_poll_slot(); the
1251 // singleton pollers (SSH etc.) gate on ConnState::CONN_ACTIVE inside their own on_poll.
1252 const ProtoHandler *ph = proto_get(conn_pool[i].proto);
1253 if (ph && ph->on_poll)
1254 {
1255 ph->on_poll(i);
1256 }
1257 }
1258
1259 // Run any callbacks app code deferred to this worker (race-free push path).
1260 pc_worker_run_deferred(worker_id);
1261}
1262
1263// HTTP's instance-bound poll pump. Installed as the HTTP ProtoHandler's on_poll at begin() (via
1264// http_proto_set_poll) so the worker dispatch loop pumps HTTP through the same uniform seam as every
1265// other protocol - no HTTP special case in the loop. Runs the file/chunk send pumps, the WebSocket +
1266// SSE drains, the keep-alive re-parse, and dispatches a completed request into this server's routes.
1267#if PC_ENABLE_EDGE_CACHE
1268// Edge-cache async-fetch pump seam (see pc_http_set_edge_poll / services/web/edge_cache/edge_cache_proxy):
1269// a cache miss suspends the client request and drives the non-blocking origin fetch from this slot's poll.
1270static bool (*s_edge_poll)(uint8_t slot) = nullptr;
1271void pc_http_set_edge_poll(bool (*fn)(uint8_t slot))
1272{
1273 s_edge_poll = fn;
1274}
1275#endif
1276
1277void PC::http_poll_slot(uint8_t i)
1278{
1279#if PC_ENABLE_EDGE_CACHE
1280 // An edge-cache origin fetch in flight for this slot owns it: pump the fetch and skip the rest of the
1281 // HTTP pipeline until it completes (and hands off to send_chunked for the cached response).
1282 if (s_edge_poll && s_edge_poll(i))
1283 {
1284 return;
1285 }
1286#endif
1287#if PC_ENABLE_FILE_SERVING
1288 // A file response in flight owns the slot: page out the next window and
1289 // skip the rest of the pipeline until the whole body has been sent.
1290 if (s_send.file[i].active)
1291 {
1292 file_send_pump(i);
1293 return;
1294 }
1295#endif
1296 // Likewise a chunked response in flight: pull + frame the next window.
1297 if (s_send.chunk[i].active)
1298 {
1299 chunk_send_pump(i);
1300 return;
1301 }
1302
1303#if PC_ENABLE_WEBSOCKET
1304 // WebSocket slot - drain ring buffer and dispatch ready frames
1305 WsConn *ws = ws_find(i);
1306 if (ws)
1307 {
1308#if PC_ENABLE_TLS
1309 if (conn_pool[i].tls)
1310 {
1311 // wss://: the rx ring holds ciphertext, so decrypt records here and
1312 // feed the frame parser, dispatching each completed frame as it
1313 // finishes (one TLS record may carry several WS frames).
1314 uint8_t tbuf[256];
1315 int n;
1316 while ((n = pc_tls_read(i, tbuf, sizeof(tbuf))) > 0)
1317 {
1318 for (int k = 0; k < n; k++)
1319 {
1320 ws_feed_byte(ws, tbuf[k]);
1322 {
1323 ws_dispatch_message(ws);
1324 ws_reset_frame(ws);
1325 }
1327 {
1328 break;
1329 }
1330 }
1332 {
1333 break;
1334 }
1335 }
1337 {
1338 ws_dispatch_close(ws);
1339 ws_free(i);
1340 pc_conn_abort_slot(i); // transport owns TLS-free + detach + reset + RST
1341 http_reset(i);
1342 }
1343 return;
1344 }
1345#endif // PC_ENABLE_TLS
1346
1347 ws_parse(ws);
1348
1350 {
1351 ws_dispatch_message(ws);
1352 ws_reset_frame(ws);
1353 }
1355 {
1356 ws_dispatch_close(ws);
1357 ws_free(i);
1358 // RFC 6455 5.5.1: close the underlying TCP connection after the close
1359 // handshake. begin_close moves the slot out of ConnState::CONN_ACTIVE so the
1360 // post-close bytes are NOT re-parsed as a new HTTP request (the
1361 // close-frame the WS layer queued still flushes during the dwell).
1363 http_reset(i);
1364 }
1365 return; // slot is owned by WS; skip HTTP dispatch
1366 }
1367#endif // PC_ENABLE_WEBSOCKET
1368
1369#if PC_ENABLE_SSE
1370 // SSE slot - connection stays open, nothing to parse from client
1371 if (pc_sse_find(i))
1372 {
1373 return;
1374 }
1375#endif // PC_ENABLE_SSE
1376
1377#if PC_ENABLE_KEEPALIVE
1378 // Keep-alive: a slot recycled after a response may already hold the next
1379 // (pipelined) request in its ring buffer with no new EvtType::EVT_DATA to trigger a
1380 // parse. Drain it here each tick so it gets dispatched. TLS slots are
1381 // skipped - their ring holds ciphertext, decrypted in the session layer.
1382 if (conn_pool[i].state == ConnState::CONN_ACTIVE && http_pool[i].parse_state != ParseState::PARSE_COMPLETE
1383#if PC_ENABLE_TLS
1384 && !conn_pool[i].tls
1385#endif
1386 )
1387 http_parse(i);
1388#endif
1389
1390#if PC_REQUEST_TIMEOUT_MS > 0
1391 // Slow-loris defense (the nginx client_header_timeout semantic): bound the request HEADER phase. A
1392 // connection that sent its first byte but has not completed its request headers within
1393 // PC_REQUEST_TIMEOUT_MS is answered 408 and closed, freeing the slot. Unlike the idle timeout, req_start_ms
1394 // is NOT reset by a trickle byte (it is armed once, on the first RX byte), so a drip-fed partial header
1395 // cannot hold a slot open indefinitely - the connection-slot DoS the user reproduced. The deadline is
1396 // scoped to the header phase (parse_state < PARSE_BODY, since every header state precedes PARSE_BODY in the
1397 // enum) so it never reaps a legitimate slow body: a large streaming upload sits in PARSE_BODY for its whole
1398 // duration and is governed by the streaming handler + idle timer, not this deadline. WebSocket / SSE were
1399 // already returned above.
1400 if (conn_pool[i].state == ConnState::CONN_ACTIVE && conn_pool[i].req_start_ms != 0 &&
1401 http_pool[i].parse_state < ParseState::PARSE_BODY &&
1402 (pc_millis() - conn_pool[i].req_start_ms) >= PC_REQUEST_TIMEOUT_MS)
1403 {
1404 conn_pool[i].req_start_ms = 0;
1405 send(i, 408, PC_MIME_TEXT_PLAIN, "Request Timeout"); // terminal error response -> connection closes
1406 return;
1407 }
1408#endif
1409
1410 // HTTP slot
1411 if (http_pool[i].parse_state == ParseState::PARSE_COMPLETE)
1412 {
1413 conn_pool[i].req_start_ms = 0; // request complete: disarm; the next keep-alive request re-arms on its 1st byte
1414 match_and_execute(i);
1415 if (http_pool[i].parse_state == ParseState::PARSE_COMPLETE)
1416 {
1417 http_reset(i);
1418 }
1419 }
1420 else if (http_pool[i].parse_state == ParseState::PARSE_ERROR)
1421 {
1422 send(i, 400, PC_MIME_TEXT_PLAIN, "Bad Request");
1423 }
1424 else if (http_pool[i].parse_state == ParseState::PARSE_ENTITY_TOO_LARGE)
1425 {
1426 send(i, 413, PC_MIME_TEXT_PLAIN, "Payload Too Large");
1427 }
1428 else if (http_pool[i].parse_state == ParseState::PARSE_URI_TOO_LONG)
1429 {
1430 send(i, 414, PC_MIME_TEXT_PLAIN, "URI Too Long");
1431 }
1432}
1433
1434bool PC::defer(uint8_t slot, pc_deferred_fn fn, void *arg) const
1435{
1436 if (slot >= MAX_CONNS)
1437 {
1438 return false;
1439 }
1440 // Route to the worker that owns the slot so the callback runs single-threaded
1441 // alongside that slot's own processing.
1442 return pc_defer(conn_pool[slot].owner, fn, arg);
1443}
1444
1445// ---------------------------------------------------------------------------
1446// Diagnostic endpoint
1447// ---------------------------------------------------------------------------
1448
1449#if PC_ENABLE_DIAG
1450void PC::diag(uint8_t slot_id)
1451{
1452 send(slot_id, 200, PC_MIME_JSON, PC_DIAG_JSON);
1453}
1454#endif
1455
1456// ---------------------------------------------------------------------------
1457// Route dispatch
1458// ---------------------------------------------------------------------------
1459
1460// True when the request on this slot used the HEAD method, whose response must
1461// carry the same headers as GET but no message body (RFC 7231 §4.3.2). External
1462// linkage (declared in server/protocore_internal.h): the split handler TUs call it.
1463bool req_is_head(uint8_t slot_id)
1464{
1465 return strcmp(http_pool[slot_id].method, "HEAD") == 0;
1466}
1467
1468// Append a method token to a comma-separated Allow list, de-duplicating.
1469static void allow_append(char *buf, size_t cap, const char *m)
1470{
1471 if (!m[0] || strstr(buf, m))
1472 {
1473 return;
1474 }
1475 size_t len = strnlen(buf, cap);
1476 if (len == 0)
1477 {
1478 pc_sb sb_buf = {buf, cap, 0, true};
1479 pc_sb_put(&sb_buf, m);
1480 if (pc_sb_finish(&sb_buf) == 0)
1481 {
1482 buf[0] = '\0';
1483 }
1484 }
1485 else
1486 {
1487 pc_sb sb1300 = {buf + len, cap - len, 0, true};
1488 pc_sb_put(&sb1300, ", ");
1489 pc_sb_put(&sb1300, m);
1490 if (pc_sb_finish(&sb1300) == 0)
1491 {
1492 sb1300.p[0] = '\0';
1493 }
1494 }
1495}
1496
1497// Send a terminal text/plain error response that closes the connection: the
1498// status reason (e.g. "405 Method Not Allowed"), one optional pre-formatted extra
1499// header (CRLF-terminated, e.g. "Allow: GET\r\n"), then Content-Type/Length and
1500// "Connection: close". Begins the ConnState::CONN_CLOSING dwell so the bytes drain before
1501// teardown; HEAD omits the body. One owner for the error-and-close path.
1502static void send_error_close(uint8_t slot_id, const char *status, const char *extra_hdr, const char *body)
1503{
1504 TcpConn *conn = &conn_pool[slot_id];
1505 if (conn->state != ConnState::CONN_ACTIVE || conn->pcb == nullptr)
1506 {
1507 http_reset(slot_id);
1508 return;
1509 }
1510
1511 int blen = (int)strnlen(body, 0xFFFF);
1512 char header[RESP_HDR_BUF_SIZE];
1513 // GCOVR_EXCL_BR_START the null arm of the extra_hdr ternary is unreachable: both callers
1514 // (send_method_not_allowed, send_too_many_requests) build a non-null header string. Kept so the
1515 // parameter stays optional for a future caller with nothing extra to add.
1516 pc_sb sb_header = {header, sizeof(header), 0, true};
1517 pc_sb_put(&sb_header, "HTTP/1.1 ");
1518 pc_sb_put(&sb_header, status);
1519 pc_sb_put(&sb_header, "\r\n");
1520 pc_sb_put(&sb_header, extra_hdr ? extra_hdr : "");
1521 pc_sb_put(&sb_header, "Content-Type: ");
1522 pc_sb_put(&sb_header, PC_MIME_TEXT_PLAIN);
1523 pc_sb_put(&sb_header, "\r\nContent-Length: ");
1524 pc_sb_i64(&sb_header, (int64_t)(blen));
1525 pc_sb_put(&sb_header, "\r\nConnection: close\r\n\r\n");
1526 int hlen = (int)pc_sb_finish(&sb_header);
1527 // GCOVR_EXCL_BR_STOP
1528
1529 // The last write carries the flush (pc_conn_send_flush = write+tcp_output in one marshal), so
1530 // an error-and-close costs one round-trip fewer - and 4xx/5xx closes are the hot path under a
1531 // flood (rate-limit / auth-lockout 429s).
1532 // GCOVR_EXCL_BR_START the blen == 0 half is unreachable: both callers pass a fixed non-empty
1533 // reason body ("Method Not Allowed" / "Too Many Requests"). The HEAD half is exercised.
1534 if (blen > 0 && !req_is_head(slot_id))
1535 {
1536 pc_conn_send(slot_id, header, (u16_t)hlen);
1537 pc_conn_send_flush(slot_id, body, (u16_t)blen);
1538 }
1539 else
1540 {
1541 pc_conn_send_flush(slot_id, header, (u16_t)hlen);
1542 }
1543 // GCOVR_EXCL_BR_STOP
1544 pc_conn_begin_close(slot_id); // dwell in ConnState::CONN_CLOSING until the response drains
1545 http_reset(slot_id);
1546}
1547
1548// Send 405 Method Not Allowed with the required Allow header (RFC 7231 §6.5.5).
1549static void send_method_not_allowed(uint8_t slot_id, const char *allow)
1550{
1551 char extra[80];
1552 pc_sb sb_extra = {extra, sizeof(extra), 0, true};
1553 pc_sb_put(&sb_extra, "Allow: ");
1554 pc_sb_put(&sb_extra, allow);
1555 pc_sb_put(&sb_extra, "\r\n");
1556 if (pc_sb_finish(&sb_extra) == 0)
1557 {
1558 extra[0] = '\0';
1559 }
1560 send_error_close(slot_id, "405 Method Not Allowed", extra, "Method Not Allowed");
1561}
1562
1563#if PC_ENABLE_AUTH_LOCKOUT
1564// The peer's family-tagged source address for the connection in slot_id (unspecified on native /
1565// no pcb). Used as the auth-lockout bucket key - the full IPv4 or IPv6 address, so a v6 peer is
1566// never flattened onto a shared v4 bucket nor folded into a collideable hash.
1567static pc_ip lockout_client_ip(uint8_t slot_id)
1568{
1569 pc_ip ip;
1571 pc_conn_remote_addr(slot_id, &ip);
1572 return ip;
1573}
1574
1575// 429 Too Many Requests with Retry-After (auth lockout active). Closes the
1576// connection - mirrors send_method_not_allowed's PCB lifecycle.
1577static void send_too_many_requests(uint8_t slot_id, uint32_t retry_after_s)
1578{
1579 char extra[40];
1580 pc_sb sb_extra2 = {extra, sizeof(extra), 0, true};
1581 pc_sb_put(&sb_extra2, "Retry-After: ");
1582 pc_sb_u32(&sb_extra2, (uint32_t)((unsigned long)retry_after_s));
1583 pc_sb_put(&sb_extra2, "\r\n");
1584 if (pc_sb_finish(&sb_extra2) == 0)
1585 {
1586 extra[0] = '\0';
1587 }
1588 send_error_close(slot_id, "429 Too Many Requests", extra, "Too Many Requests");
1589}
1590#endif // PC_ENABLE_AUTH_LOCKOUT
1591
1592bool PC::route_admits(const Route *r, uint8_t slot_id, HttpReq *req) const
1593{
1594 // GCOVR_EXCL_START unreachable: every entry below _route_count was filled by fill_route_base,
1595 // which sets is_active, and nothing ever clears it - so the dispatcher never walks an inactive
1596 // slot. Kept because is_active is what makes an unfilled table entry safe to skip.
1597 if (!r->is_active)
1598 {
1599 return false;
1600 }
1601 // GCOVR_EXCL_STOP
1602 bool matched = r->is_regex ? regex_match(r->path, req->path)
1603 : r->is_param ? match_path_params(r->path, req->path, req)
1604 : path_matches(r->path, r->is_wildcard, req->path);
1605 if (!matched)
1606 {
1607 return false;
1608 }
1609 // Per-route interface gate: a route bound to STA/AP is invisible on the
1610 // other interface (falls through to other routes / 404).
1611 if (r->iface_filter != pc_iface::PC_IFACE_ANY && r->iface_filter != pc_conn_iface(slot_id))
1612 {
1613 return false;
1614 }
1615 return true;
1616}
1617
1618#if PC_ENABLE_CSRF
1619bool PC::pc_csrf_gate(uint8_t slot_id, HttpReq *req, HttpMethod method)
1620{
1621 // Built-in token endpoint: GET /csrf issues a signed token (also set as the
1622 // csrf cookie) for clients to echo in X-CSRF-Token on state-changing requests.
1623 if (method == HttpMethod::HTTP_GET && strcmp(req->path, "/csrf") == 0)
1624 {
1625 char tok[CSRF_TOKEN_BUF];
1626 if (pc_csrf_issue(tok, sizeof(tok)) > 0)
1627 {
1628 set_cookie(slot_id, "csrf", tok, "Path=/; SameSite=Strict");
1629 char body[CSRF_TOKEN_BUF + 16];
1630 pc_sb sb_body = {body, sizeof(body), 0, true};
1631 pc_sb_put(&sb_body, "{\"token\":\"");
1632 pc_sb_put(&sb_body, tok);
1633 pc_sb_put(&sb_body, "\"}");
1634 if (pc_sb_finish(&sb_body) == 0)
1635 {
1636 body[0] = '\0';
1637 }
1638 send(slot_id, 200, PC_MIME_JSON, body);
1639 }
1640 else
1641 {
1642 send(slot_id, 500, PC_MIME_TEXT_PLAIN, "CSRF unavailable");
1643 }
1644 return true;
1645 }
1646
1647 // Enforce CSRF on every state-changing method: require a valid signed
1648 // X-CSRF-Token header (GET / HEAD / OPTIONS are exempt - not state-changing).
1649 if (method == HttpMethod::HTTP_POST || method == HttpMethod::HTTP_PUT || method == HttpMethod::HTTP_PATCH ||
1650 method == HttpMethod::HTTP_DELETE)
1651 {
1652 const char *tok = http_get_header(req, "X-CSRF-Token");
1653 if (!tok || !pc_csrf_verify(tok))
1654 {
1655 send(slot_id, 403, PC_MIME_TEXT_PLAIN, "CSRF token missing or invalid");
1656 return true;
1657 }
1658 }
1659 return false;
1660}
1661#endif // PC_ENABLE_CSRF
1662
1663#if PC_ENABLE_WEBSOCKET
1664void PC::handle_ws_route(uint8_t slot_id, HttpReq *req, HttpMethod method, const Route *r)
1665{
1666 const char *upgrade_hdr = http_get_header(req, "Upgrade");
1667 // RFC 6455 4.2.1: a valid handshake needs Upgrade: websocket AND a Connection
1668 // header that includes the "Upgrade" token.
1669 bool is_ws_upgrade = (method == HttpMethod::HTTP_GET) && upgrade_hdr &&
1670 (strcasecmp(upgrade_hdr, "websocket") == 0) &&
1671 conn_has_token(http_get_header(req, "Connection"), "upgrade");
1672 if (!is_ws_upgrade)
1673 {
1674 send(slot_id, 400, PC_MIME_TEXT_PLAIN, "WebSocket upgrade required");
1675 return;
1676 }
1677 // RFC 6455 §4.2.1: only version 13 is supported; otherwise 426.
1678 const char *ws_ver = http_get_header(req, "Sec-WebSocket-Version");
1679 if (!ws_ver || strcmp(ws_ver, "13") != 0)
1680 {
1681 ws_send_version_required(slot_id);
1682 return;
1683 }
1684 // A failed upgrade here means a malformed/oversized Sec-WebSocket-Key (a
1685 // client error, RFC 6455 4.2.1), so answer 400 rather than 503.
1686 if (!ws_do_upgrade(slot_id, req, r->ws_connect))
1687 {
1688 send(slot_id, 400, PC_MIME_TEXT_PLAIN, "Bad WebSocket handshake");
1689 }
1690}
1691#endif // PC_ENABLE_WEBSOCKET
1692
1693#if PC_ENABLE_AUTH
1694bool PC::authorize_request(uint8_t slot_id, HttpReq *req, const Route *r)
1695{
1696#if PC_ENABLE_AUTH_LOCKOUT
1697 pc_ip cip = lockout_client_ip(slot_id);
1698#if PC_ENABLE_FORWARDED_TRUST
1699 // Behind a trusted reverse proxy, key the lockout on the original client (the proxy's Forwarded /
1700 // X-Forwarded-For), not the proxy's shared TCP address. Ignored for a direct/untrusted peer, so a
1701 // spoofed header can neither evade a lockout nor frame another address.
1702 {
1703 char fbuf[PC_IP_STR_MAX];
1704 const char *fwd = http_forwarded_client(req, fbuf, sizeof(fbuf), nullptr) ? fbuf : nullptr;
1705 pc_ip eff;
1706 pc_forwarded_effective_ip(&cip, fwd, &eff);
1707 cip = eff;
1708 }
1709#endif
1710 uint32_t now = (uint32_t)millis();
1711 uint32_t remain = auth_lockout_remaining_ms(&cip, now);
1712 if (remain > 0)
1713 {
1714 // Address is locked out: 429 + Retry-After, no credential check.
1715 send_too_many_requests(slot_id, (remain + 999) / 1000);
1716 return false;
1717 }
1718#endif
1719 bool stale = false;
1720 bool ok = r->auth_digest ? check_digest_auth(slot_id, req, r, &stale) : check_basic_auth(slot_id, req, r);
1721#if PC_ENABLE_AUTH_LOCKOUT
1722 // A stale-nonce retry carries valid credentials, so it is not a failed
1723 // attempt: don't count it toward the lockout (nor reset the counter).
1724 if (ok)
1725 {
1726 auth_lockout_succeed(&cip);
1727 }
1728 else if (!stale)
1729 {
1730 auth_lockout_fail(&cip, now);
1731 }
1732#endif
1733 if (!ok)
1734 {
1735 send_unauth(slot_id, r, stale);
1736 return false;
1737 }
1738 return true;
1739}
1740#endif // PC_ENABLE_AUTH
1741
1742bool PC::dispatch_matched_route(uint8_t slot_id, HttpReq *req, HttpMethod method, Route *r, bool *path_matched,
1743 char *allow_buf, size_t allow_cap)
1744{
1745#if PC_ENABLE_WEBSOCKET
1746 if (r->type == RouteType::ROUTE_WS)
1747 {
1748 handle_ws_route(slot_id, req, method, r);
1749 return true;
1750 }
1751#endif // PC_ENABLE_WEBSOCKET
1752
1753#if PC_ENABLE_SSE
1754 if (r->type == RouteType::ROUTE_SSE)
1755 {
1756 if (!pc_sse_do_upgrade(slot_id, req, r->pc_sse_connect))
1757 {
1758 send(slot_id, 503, PC_MIME_TEXT_PLAIN, "Service Unavailable");
1759 }
1760 return true;
1761 }
1762#endif // PC_ENABLE_SSE
1763
1764#if PC_ENABLE_FILE_SERVING
1765 if (r->type == RouteType::ROUTE_STATIC)
1766 {
1767 // Static mounts answer GET (and HEAD via GET); other methods → 405.
1768 if (method != HttpMethod::HTTP_GET && method != HttpMethod::HTTP_HEAD)
1769 {
1770 *path_matched = true;
1771 allow_append(allow_buf, allow_cap, "GET");
1772 allow_append(allow_buf, allow_cap, "HEAD");
1773 return false;
1774 }
1775 serve_static_request(slot_id, req, r);
1776 return true;
1777 }
1778#endif // PC_ENABLE_FILE_SERVING
1779
1780 // RouteType::ROUTE_HTTP - a HEAD request is served by the GET handler with the
1781 // response body suppressed (RFC 7231 §4.3.2).
1782 bool method_ok = (r->method == method) || (method == HttpMethod::HTTP_HEAD && r->method == HttpMethod::HTTP_GET);
1783 if (!method_ok)
1784 {
1785 // Path matches but method differs - record it for a 405 + Allow.
1786 *path_matched = true;
1787 allow_append(allow_buf, allow_cap, method_name(r->method));
1788 // A GET route also answers HEAD, so advertise it in Allow.
1789 if (r->method == HttpMethod::HTTP_GET)
1790 {
1791 allow_append(allow_buf, allow_cap, "HEAD");
1792 }
1793 return false;
1794 }
1795#if PC_ENABLE_AUTH
1796 if (r->auth_required && !authorize_request(slot_id, req, r))
1797 {
1798 return true; // 401/429 already sent
1799 }
1800#endif // PC_ENABLE_AUTH
1801 r->callback(slot_id, req);
1802 return true;
1803}
1804
1805void PC::match_and_execute(uint8_t slot_id)
1806{
1807 HttpReq *req = &http_pool[slot_id];
1808 HttpMethod method = parse_method(req->method);
1809
1810 // Start each request with no carried-over custom response headers or
1811 // captured path parameters.
1812 _extra_hdr[slot_id][0] = '\0';
1813 req->path_param_count = 0;
1814
1815 // Built-in rate limiter first (cheapest rejection under flood), then the
1816 // user middleware chain. Either may short-circuit with a response.
1817 if (rate_limit_check(slot_id))
1818 {
1819 return;
1820 }
1821 if (run_middleware(slot_id, req))
1822 {
1823 return;
1824 }
1825
1826#if PC_ENABLE_WEBDAV
1827 // A WebDAV mount owns its whole subtree and every method on it (including
1828 // PROPFIND/MKCOL/etc., which parse_method() does not recognize), so intercept
1829 // before the unknown-method 501 and the normal route loop.
1830 if (try_serve_dav(slot_id, req))
1831 {
1832 return;
1833 }
1834#endif
1835
1836 // CORS preflight
1837 if (method == HttpMethod::HTTP_OPTIONS && _cors_enabled)
1838 {
1839 send_empty(slot_id, 204);
1840 return;
1841 }
1842
1843#if PC_ENABLE_CSRF
1844 if (pc_csrf_gate(slot_id, req, method))
1845 {
1846 return;
1847 }
1848#endif
1849
1850 // RFC 7230 §3.3.1: reject Transfer-Encoding
1851 if (http_get_header(req, "Transfer-Encoding") != nullptr)
1852 {
1853 send(slot_id, 501, PC_MIME_TEXT_PLAIN, "Not Implemented");
1854 return;
1855 }
1856
1857 // RFC 7231 §6.5.2: a method the server does not implement → 501.
1858 if (method == HttpMethod::HTTP_METHOD_UNKNOWN)
1859 {
1860 send(slot_id, 501, PC_MIME_TEXT_PLAIN, "Not Implemented");
1861 return;
1862 }
1863
1864 // For RFC 7231 §6.5.5: if a path matches but no method does, answer 405
1865 // with an Allow header listing the methods registered for that path.
1866 bool path_matched = false;
1867 char allow_buf[64];
1868 allow_buf[0] = '\0';
1869
1870 for (uint8_t i = 0; i < _route_count; i++)
1871 {
1872 Route *r = &_routes[i];
1873 if (!route_admits(r, slot_id, req))
1874 {
1875 continue;
1876 }
1877 if (dispatch_matched_route(slot_id, req, method, r, &path_matched, allow_buf, sizeof(allow_buf)))
1878 {
1879 return;
1880 }
1881 }
1882
1883 // Path existed but the method was not allowed (RFC 7231 §6.5.5).
1884 if (path_matched)
1885 {
1886 send_method_not_allowed(slot_id, allow_buf);
1887 return;
1888 }
1889
1890 if (_not_found_handler)
1891 {
1892 _not_found_handler(slot_id, req);
1893 }
1894 else
1895 {
1896 send(slot_id, 404, PC_MIME_TEXT_PLAIN, "Not Found");
1897 }
1898}
1899
1900/*
1901 * Build and transmit an HTTP response with a body.
1902 *
1903 * Uses a 512-byte stack buffer for headers. CORS headers are appended when
1904 * `_cors_enabled`. The slot is freed (state → ConnState::CONN_FREE, pcb → nullptr)
1905 * *before* the tcp_write + tcp_close sequence to ensure any error callback
1906 * that lwIP fires during the write sees the slot as already released.
1907 *
1908 * If the slot's connection is not active (e.g., already timed-out or the
1909 * PCB is null) the slot is reset and the function returns without writing.
1910 *
1911 * @param slot_id Connection slot index.
1912 * @param code HTTP status code, e.g. 200.
1913 * @param content_type MIME type string, e.g. "application/json".
1914 * @param payload Null-terminated body string to send.
1915 */
1916void PC::send(uint8_t slot_id, int code, const char *content_type, const char *payload)
1917{
1918 // Null-terminated convenience wrapper over the explicit-length send.
1919 send(slot_id, code, content_type, (const uint8_t *)payload, payload ? strnlen(payload, 0xFFFF) : 0);
1920}
1921
1922void PC::send(uint8_t slot_id, int code, const char *content_type, const uint8_t *body, size_t body_len)
1923{
1924 if (slot_id >= CONN_POOL_SLOTS)
1925 {
1926 return; // guard the public entry: never index conn_pool out of range
1927 }
1928 const char *payload = (const char *)body;
1929 TcpConn *conn = &conn_pool[slot_id];
1930#if PC_ENABLE_HTTP2 || PC_ENABLE_HTTP3
1931 // A self-framing protocol (HTTP/2, HTTP/3) installed its own response sink at negotiation /
1932 // dispatch time; route through it and let it own its framing + connection lifecycle. This runs
1933 // before the HTTP/1.1 pcb check because that check is a TCP-transport concern (the HTTP/3 slot
1934 // has no pcb by design, and an h2 connection manages its own).
1935 if (conn->pc_resp_sink)
1936 {
1937 conn->pc_resp_sink(slot_id, code, content_type, payload, body_len);
1938 return;
1939 }
1940#endif
1941 if (conn->state != ConnState::CONN_ACTIVE || conn->pcb == nullptr)
1942 {
1943 http_reset(slot_id);
1944 return;
1945 }
1946
1947 int payload_len = (int)(body_len > 0xFFFF ? 0xFFFF : body_len);
1948
1949 bool keep;
1950 const char *cl = pc_resp_conn_hdr(slot_id, &keep);
1951
1952 char header[RESP_HDR_BUF_SIZE];
1953 pc_sb sb_header2 = {header, sizeof(header), 0, true};
1954 pc_sb_put(&sb_header2, "HTTP/1.1 ");
1955 pc_sb_i64(&sb_header2, (int64_t)(code));
1956 pc_sb_put(&sb_header2, " ");
1957 pc_sb_put(&sb_header2, status_text(code));
1958 pc_sb_put(&sb_header2, "\r\nContent-Type: ");
1959 pc_sb_put(&sb_header2, content_type);
1960 pc_sb_put(&sb_header2, "\r\nContent-Length: ");
1961 pc_sb_i64(&sb_header2, (int64_t)(payload_len));
1962 pc_sb_put(&sb_header2, "\r\n");
1963 int hlen = (int)pc_sb_finish(&sb_header2);
1964 hlen = append_resp_trailer(header, sizeof(header), hlen, slot_id, cl);
1965 if (hlen == 0)
1966 {
1967 // The headers do not fit RESP_HDR_BUF_SIZE (an over-long content type, or a custom-header
1968 // block that filled the buffer). Truncating them would emit a header block with no
1969 // terminating CRLF and desync the connection, so a fixed reply that always fits goes out
1970 // instead and the connection closes.
1972 pc_resp_end(slot_id, 500, 0, false);
1973 return;
1974 }
1975
1976 // The slot stays ConnState::CONN_ACTIVE through the write for both paths; pc_resp_end then
1977 // begins the ConnState::CONN_CLOSING dwell on the close path (finalized once ACKed).
1978
1979 bool head = req_is_head(slot_id);
1980
1981 // HEAD responses carry the headers (incl. Content-Length) but no body. For a
1982 // body that fits the header scratch, coalesce headers+body into a single send
1983 // so the response costs one tcpip_thread round-trip instead of two. The final
1984 // write also carries the flush (pc_conn_send_flush), so pc_resp_end skips it -
1985 // a keep-alive small response is now one marshal (write+output) instead of two.
1986 if (!head && payload_len > 0 && (size_t)hlen + (size_t)payload_len <= sizeof(header))
1987 {
1988 memcpy(header + hlen, payload, (size_t)payload_len);
1989 pc_conn_send_flush(slot_id, header, (u16_t)(hlen + payload_len));
1990 }
1991 else if (!head && payload_len > 0)
1992 {
1993 pc_conn_send(slot_id, header, (u16_t)hlen);
1994 pc_conn_send_flush(slot_id, payload, (u16_t)payload_len);
1995 }
1996 else
1997 {
1998 pc_conn_send_flush(slot_id, header, (u16_t)hlen);
1999 }
2000
2001 pc_resp_end(slot_id, code, payload_len, keep, /*pre_flushed=*/true);
2002}
2003
2004/*
2005 * Build and transmit an HTTP response with no body.
2006 *
2007 * Used for CORS preflight (204) and any response where only status headers
2008 * are needed. Behaves identically to send() regarding slot lifecycle and
2009 * PCB ownership transfer - the slot is freed before the lwIP write call.
2010 *
2011 * @param slot_id Connection slot index.
2012 * @param code HTTP status code, e.g. 204.
2013 */
2014void PC::send_empty(uint8_t slot_id, int code)
2015{
2016 if (slot_id >= CONN_POOL_SLOTS)
2017 {
2018 return;
2019 }
2020 TcpConn *conn = &conn_pool[slot_id];
2021#if PC_ENABLE_HTTP2 || PC_ENABLE_HTTP3
2022 if (conn->pc_resp_sink)
2023 {
2024 conn->pc_resp_sink(slot_id, code, "text/plain", "", 0);
2025 return;
2026 }
2027#endif
2028 if (conn->state != ConnState::CONN_ACTIVE || conn->pcb == nullptr)
2029 {
2030 http_reset(slot_id);
2031 return;
2032 }
2033
2034 bool keep;
2035 const char *cl = pc_resp_conn_hdr(slot_id, &keep);
2036
2037 char header[RESP_HDR_BUF_SIZE];
2038 pc_sb sb_header3 = {header, sizeof(header), 0, true};
2039 pc_sb_put(&sb_header3, "HTTP/1.1 ");
2040 pc_sb_i64(&sb_header3, (int64_t)(code));
2041 pc_sb_put(&sb_header3, " ");
2042 pc_sb_put(&sb_header3, status_text(code));
2043 pc_sb_put(&sb_header3, "\r\nContent-Length: 0\r\n");
2044 int hlen = (int)pc_sb_finish(&sb_header3);
2045 hlen = append_resp_trailer(header, sizeof(header), hlen, slot_id, cl);
2046
2047 pc_conn_send_flush(slot_id, header, (u16_t)hlen);
2048
2049 pc_resp_end(slot_id, code, 0, keep, /*pre_flushed=*/true);
2050}
2051
2052void PC::redirect(uint8_t slot_id, int code, const char *location)
2053{
2054 if (slot_id >= MAX_CONNS)
2055 {
2056 return;
2057 }
2058 TcpConn *conn = &conn_pool[slot_id];
2059 if (conn->state != ConnState::CONN_ACTIVE || conn->pcb == nullptr)
2060 {
2061 http_reset(slot_id);
2062 return;
2063 }
2064
2065 // Only the redirect status codes are valid here; anything else → 302.
2066 switch (code)
2067 {
2068 case 301:
2069 case 302:
2070 case 303:
2071 case 307:
2072 case 308:
2073 break;
2074 default:
2075 code = 302;
2076 break;
2077 }
2078
2079 bool keep;
2080 const char *cl = pc_resp_conn_hdr(slot_id, &keep);
2081
2082 char header[RESP_HDR_BUF_SIZE];
2083 pc_sb sb_header4 = {header, sizeof(header), 0, true};
2084 pc_sb_put(&sb_header4, "HTTP/1.1 ");
2085 pc_sb_i64(&sb_header4, (int64_t)(code));
2086 pc_sb_put(&sb_header4, " ");
2087 pc_sb_put(&sb_header4, status_text(code));
2088 pc_sb_put(&sb_header4, "\r\nLocation: ");
2089 pc_sb_put(&sb_header4, location);
2090 pc_sb_put(&sb_header4, "\r\nContent-Length: 0\r\n");
2091 int hlen = (int)pc_sb_finish(&sb_header4);
2092 hlen = append_resp_trailer(header, sizeof(header), hlen, slot_id, cl);
2093
2094 pc_conn_send_flush(slot_id, header, (u16_t)hlen);
2095
2096 pc_resp_end(slot_id, code, 0, keep, /*pre_flushed=*/true);
2097}
Per-peer brute-force lockout for HTTP auth (PC_ENABLE_AUTH_LOCKOUT).
Base64 encoder/decoder.
#define BODY_BUF_SIZE
Definition c2_defaults.h:67
#define MAX_HEADERS
Definition c2_defaults.h:64
#define MAX_CONNS
Definition c2_defaults.h:47
#define MAX_ROUTES
Definition c2_defaults.h:61
static void pool_init(const WebServerConfig *cfg=nullptr)
Initialize the connection pool and store the runtime config.
Definition tcp.cpp:819
static void stop()
Abort all active connections and reset the pool to ConnState::CONN_FREE.
Definition tcp.cpp:838
Single-port HTTP server with deterministic, zero-allocation execution.
Definition protocore.h:348
PC()
Construct a PC with an empty routing table.
void on_request_log(RequestLogCb cb)
Install a per-request access-log callback (one hook, no buffering).
void send_empty(uint8_t slot_id, int code)
Send a headers-only HTTP response and close the connection.
void on_regex(const char *pattern, HttpMethod method, Handler callback)
Register a route whose path is a regular expression.
int32_t restart(const WebServerConfig *cfg=nullptr)
Hard-reset all connections and re-open all registered listeners.
void redirect(uint8_t slot_id, int code, const char *location)
Send an HTTP redirect (Location header, empty body) and close.
int32_t listen(uint16_t port, ConnProto proto=ConnProto::PROTO_HTTP)
Register a port to listen on when begin() is called.
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 set_ap_ip(uint32_t ap_ip)
Tell the server the softAP IPv4 address for STA/AP route filtering.
void service_once(int worker_id=0)
Run exactly one service iteration for worker worker_id (the body driven by that worker's task,...
int32_t begin(const WebServerConfig *cfg=nullptr)
Initialize all connection slots and open all registered listeners.
void on(const char *path, HttpMethod method, Handler callback)
Register a route handler.
void stop()
Gracefully stop the server.
bool defer(uint8_t slot, pc_deferred_fn fn, void *arg) const
Run fn(arg) on the worker that owns connection slot.
void handle()
Drive the server - call every Arduino loop() iteration.
void set_cors(const char *origin)
Enable CORS by pre-building the Access-Control headers.
void set_cache_control(const char *value)
Set the Cache-Control header emitted for static files.
void on_not_found(Handler callback)
Register a fallback handler for unmatched requests.
void set_cookie(uint8_t slot_id, const char *name, const char *value, const char *attrs=nullptr)
Queue a Set-Cookie response header for the next send on this slot.
Definition response.cpp:359
void http_poll_slot(uint8_t slot_id)
The instance-bound HTTP poll pump for one slot (the HTTP ProtoHandler's on_poll).
Pluggable monotonic clock for all library timing.
uint32_t pc_millis(void)
The library's monotonic time at 1000 Hz (milliseconds).
Definition clock.h:71
Stateless HMAC-signed CSRF token (PC_ENABLE_CSRF).
Trusted-reverse-proxy resolution of a forwarded client address (PC_ENABLE_FORWARDED_TRUST).
Base-16 conversion between raw bytes and their ASCII digits.
HTTP delivery optimizations: stale-while-revalidate, Range/206 delta fetch, SW precache (PC_ENABLE_HT...
const char * http_get_header(const HttpReq *req, const char *key)
Look up a header value by name (case-insensitive).
HttpReq http_pool[CONN_POOL_SLOTS]
Pool of parser contexts, one per connection-pool slot (incl. reserved dispatch slots).
bool http_forwarded_client(const HttpReq *req, char *ip_out, size_t ip_cap, bool *is_https)
Recover the original client from a reverse-proxy Forwarded (RFC 7239) or de-facto X-Forwarded-For / X...
@ PARSE_ERROR
Unrecoverable parse failure → 400.
@ PARSE_ENTITY_TOO_LARGE
Content-Length > BODY_BUF_SIZE → 413.
@ PARSE_BODY
Reading the request body.
@ PARSE_COMPLETE
Full request parsed; ready for dispatch.
@ PARSE_URI_TOO_LONG
Path exceeds MAX_PATH_LEN → 414.
@ HTTP_11
HTTP/1.1 - persistent connection by default.
@ PC_IP_NONE
empty / unparsed
#define PC_IP_STR_MAX
Longest text an pc_ip_format can produce, including the NUL (RFC 5952 v4-mapped).
Definition ip.h:58
int32_t listener_add(uint8_t idx, uint16_t port, ConnProto proto, bool tls)
Create a listening socket on port and register it at idx.
Definition listener.cpp:552
void listener_stop_all()
Stop all active listeners.
Definition listener.cpp:644
Layer 4 (Listener) - per-port TCP listener abstraction.
Shared HTTP Content-Type ("MIME") string constants (one source of truth).
size_t pc_ntp_http_date(char *out, size_t out_cap)
Format the current time as an RFC 7231 IMF-fixdate (HTTP Date).
void http_reset(uint8_t slot_id)
Reset the HTTP parser for a connection slot.
void http_proto_set_poll(void(*fn)(uint8_t slot))
Install the HTTP per-slot poll pump (the routing core's instance-bound on_poll).
void http_parse(uint8_t slot_id)
Drain the transport ring buffer and advance the HTTP parser.
Layer 6 (Presentation) - wires the transport ring buffer to the HTTP parser.
Layer 5 (Session) - per-protocol connection handler dispatch table.
const ProtoHandler * proto_get(ConnProto proto)
Look up the handler for proto.
Definition session.cpp:46
void fill_route_base(Route *r, const char *path)
Register a route in the route table.
const char * status_text(int code)
Convert an HTTP status code to its standard reason phrase.
SendCtx s_send
const size_t PC_RESP_HDR_OVERFLOW_LEN
Length of PC_RESP_HDR_OVERFLOW, taken with sizeof where the array bound is still visible.
bool req_is_head(uint8_t slot_id)
True if the request in slot slot_id used the HEAD method (send headers, no body).
const char PC_RESP_HDR_OVERFLOW[]
The fixed reply sent when a response's own headers will not fit RESP_HDR_BUF_SIZE.
Layer 7 (Application) - public HTTP routing API.
void(* Handler)(uint8_t slot_id, HttpReq *request)
Callback signature for HTTP request handlers.
Definition protocore.h:106
void(* RequestLogCb)(const char *method, const char *path, int status, int body_len)
Per-request access-log callback (see PC::on_request_log()).
Definition protocore.h:126
@ ROUTE_HTTP
Standard HTTP request/response.
HttpMethod
HTTP request methods supported by the router.
Definition protocore.h:77
@ HTTP_METHOD_UNKNOWN
Unrecognized method token → 501 Not Implemented.
@ HTTP_DELETE
Idempotent delete.
@ HTTP_POST
Non-idempotent create / action.
@ HTTP_OPTIONS
Capability query / CORS preflight.
@ HTTP_PATCH
Partial update.
@ HTTP_GET
Safe, idempotent read.
@ HTTP_PUT
Idempotent replace.
@ HTTP_HEAD
Same as GET but no response body.
@ PC_ERR_LISTENER_FULL
listen(): listener pool (MAX_LISTENERS) is full.
@ PC_ERR_NO_LISTENERS
begin() called before any listen() / begin(port).
@ PC_ERR_LISTEN_FAILED
A listener failed to open (bind/listen/lwIP error).
@ PC_OK
Success.
#define CONN_POOL_SLOTS
#define PC_ENABLE_HTTP3
HTTP/3 (RFC 9114) over QUIC (RFC 9000) - implemented, host-tested end-to-end (HW verification pending...
#define MAX_LISTENERS
Maximum number of simultaneously active listener ports.
ConnProto
Application protocol spoken on a listener port or connection slot.
@ PROTO_HTTP
HTTP/1.1 with optional WS and SSE upgrades.
#define PC_REQUEST_TIMEOUT_MS
Request-header read deadline in milliseconds (slow-loris defense). Default 10 s; 0 disables.
#define CACHE_CONTROL_BUF_SIZE
Size of the optional Cache-Control header line stored in PC.
#define RESP_HDR_BUF_SIZE
Stack buffer for HTTP response header lines in send() / send_empty() / send_unauth() / serve_file().
#define QUERY_VAL_LEN
Maximum query-parameter value length.
#define MAX_MIDDLEWARE
Maximum globally-registered middleware functions.
#define MAX_PATH_LEN
Maximum URL path length (including leading /).
#define PC_ENABLE_TLS
TLS (HTTPS/WSS) via mbedTLS with a static memory pool (ESP32-only).
#define MAX_AUTH_LEN
Maximum username or password length for HTTP Basic Authentication.
#define CORS_HDR_BUF_SIZE
Size of the pre-built CORS header block stored in PC.
#define PC_KEEPALIVE_MAX_REQUESTS
Maximum requests served on one keep-alive connection before it is closed.
#define QUERY_KEY_LEN
Maximum query-parameter key length.
#define MAX_PATH_PARAMS
Maximum number of :name path parameters captured per route match.
pc_iface
Network interface a connection arrived on (for per-route filtering).
@ PC_IFACE_STA
Station interface (joined to an AP / your LAN).
@ PC_IFACE_ANY
Unknown / no filter (matches any interface).
Library-private declarations shared between protocore.cpp and the src/server/*.cpp request-handler tr...
bool regex_match(const char *pattern, const char *path)
Whole-path regex match (anchored both ends; bounded by RE_MAX_STEPS, fails closed)....
Definition regex.cpp:226
void server_tick(int worker_id)
Drive the session layer for one Arduino loop iteration.
Definition session.cpp:103
SHA-1 (FIPS 180-4) - one-shot digest.
SHA-256 (FIPS 180-4) - streaming context and one-shot API.
void pc_sse_init()
Initialize all SSE pool slots to inactive.
Definition sse.cpp:16
SseConn * pc_sse_find(uint8_t slot_id)
Find the SseConn for a given TCP slot, or nullptr.
Definition sse.cpp:43
Bounded no-heap string builder that fails closed on overflow (one shared copy).
size_t pc_sb_finish(pc_sb *b)
NUL-terminate and return the built length, or 0 if the build overflowed.
Definition strbuf.h:648
void pc_sb_i64(pc_sb *b, int64_t v)
Append v as signed decimal (64-bit), with a leading '-' when negative.
Definition strbuf.h:315
void pc_sb_put(pc_sb *b, const char *s)
Append NUL-terminated s; leaves the buffer untouched and clears ok if it would not fit.
Definition strbuf.h:60
void pc_sb_u32(pc_sb *b, uint32_t v)
Append v as decimal (no leading zeros; "0" for zero).
Definition strbuf.h:303
bool active
a chunked response is in progress on this slot.
A single HTTP header field (key: value).
Definition http_parser.h:90
char val[MAX_VAL_LEN]
Field value, null-terminated.
Definition http_parser.h:92
char key[MAX_KEY_LEN]
Field name, null-terminated.
Definition http_parser.h:91
Fully-parsed HTTP/1.1 request.
Header headers[MAX_HEADERS]
Captured header fields.
char query[MAX_QUERY_LEN]
Raw query string (after ?).
uint8_t body[BODY_BUF_SIZE+1]
Stored body bytes, always null-terminated.
QueryParam path_params[MAX_PATH_PARAMS]
:name captures from the matched route.
uint8_t header_count
Valid entries in headers[].
ParseState parse_state
Current parser state.
size_t path_idx
Write cursor into path[].
size_t content_length
Value of Content-Length header (0 if absent).
uint8_t path_param_count
Valid entries in path_params[].
size_t body_len
Bytes stored in body[] (≤ BODY_BUF_SIZE).
HttpVersion version
Protocol version parsed from the request line.
char path[MAX_PATH_LEN]
URL path, null-terminated; no query string.
char method[PC_METHOD_BUF_SIZE]
HTTP method, null-terminated (OPTIONS, or WebDAV methods when enabled).
size_t body_bytes_read
Body bytes received (may exceed BODY_BUF_SIZE).
size_t query_idx
Write cursor into query[].
PC * http_instance
PC * worker_server
Per-protocol connection event/poll callbacks (Layer 5 dispatch vtable).
void(* on_poll)(uint8_t slot)
Called for an active slot each handle() loop (nullable).
A single parsed query-string parameter.
Definition http_parser.h:97
char val[QUERY_VAL_LEN]
Parameter value (empty string if absent).
Definition http_parser.h:99
char key[QUERY_KEY_LEN]
Parameter name, null-terminated.
Definition http_parser.h:98
Internal route entry stored in the routing table.
Definition protocore.h:244
bool is_param
true when the path contains a :name segment.
Definition protocore.h:275
RouteType type
HTTP, WS, or SSE.
Definition protocore.h:246
bool is_regex
true when the path is a regex (see on_regex()).
Definition protocore.h:276
Handler callback
HTTP handler (RouteType::ROUTE_HTTP only).
Definition protocore.h:248
pc_iface iface_filter
Interface gate; pc_iface::PC_IFACE_ANY (0) = match any interface.
Definition protocore.h:277
HttpMethod method
HTTP method (RouteType::ROUTE_HTTP only).
Definition protocore.h:247
bool is_active
false for unused table slots.
Definition protocore.h:273
bool is_wildcard
true when path ends with *.
Definition protocore.h:274
char path[MAX_PATH_LEN]
Null-terminated path pattern.
Definition protocore.h:245
ChunkSend chunk[MAX_CONNS]
A single TCP connection context.
Definition tcp.h:72
uint32_t req_start_ms
Definition tcp.h:77
struct tcp_pcb * pcb
lwIP PCB; null when slot is free.
Definition tcp.h:75
pc_iface iface
Interface this connection arrived on; set at accept time.
Definition tcp.h:93
pc_atomic< ConnState > state
Lifecycle state; acquire/release for inter-task visibility.
Definition tcp.h:74
Runtime-tunable server parameters.
WebSocket connection state stored in ws_pool[].
Definition websocket.h:125
uint8_t ws_id
Index into ws_pool[] (set at init).
Definition websocket.h:126
WsParseState parse_state
Current frame parser state.
Definition websocket.h:130
A v4 or v6 address in network (big-endian) byte order.
Definition ip.h:52
pc_ip_family family
address family tag
Definition ip.h:53
Bump-append target; ok latches false once an append would overflow cap.
Definition strbuf.h:30
char * p
Definition strbuf.h:31
bool ok
Definition strbuf.h:34
void pc_conn_set_state(uint8_t slot, ConnState st)
The single conn_pool[slot].state write path. Writes the state (release) and keeps the free-slot bitma...
Definition tcp.cpp:446
void pc_conn_abort_slot(uint8_t slot)
Hard-abort connection slot (RST) for a fatal condition. The transport owns the teardown order: free t...
Definition tcp.cpp:682
TcpConn conn_pool[CONN_POOL_SLOTS]
Static pool of connection contexts. Defined in tcp.cpp. Sized CONN_POOL_SLOTS: MAX_CONNS TCP slots pl...
Definition tcp.cpp:425
void pc_conn_flush(uint8_t slot)
Flush queued bytes / finish the send on slot (TLS-aware).
Definition tcp.cpp:561
uint32_t pc_ap_ip
softAP IPv4 address (network byte order) for STA/AP interface tagging.
Definition tcp.cpp:487
void pc_conn_ack_consumed(uint8_t slot)
Reopen the TCP receive window by however much slot has drained.
Definition tcp.cpp:595
bool pc_conn_send(uint8_t slot, const void *data, u16_t len)
Send len bytes on connection slot (copies data; TLS-aware).
Definition tcp.cpp:500
void pc_conn_begin_close(uint8_t slot_id)
Begin a graceful close that dwells in ConnState::CONN_CLOSING until the peer ACKs.
Definition tcp.cpp:778
bool pc_conn_remote_addr(uint8_t slot, pc_ip *out)
The connected peer's address as a family-tagged pc_ip (IPv4 or IPv6).
Definition tcp.cpp:912
bool pc_conn_send_flush(uint8_t slot, const void *data, u16_t len)
Send len bytes on slot and flush in a single tcpip_thread round-trip.
Definition tcp.cpp:518
@ CONN_ACTIVE
Live connection; PCB is valid.
@ CONN_FREE
Slot is available; no PCB is attached.
size_t pc_time_http_date(char *out, size_t out_cap)
The current best time (pc_time_now, any registered NTP / GPS / RTC / ... source) formatted as an RFC ...
Multi-source time fallback matrix (PC_ENABLE_TIME_SOURCE).
Deterministic TLS engine: mbedTLS over a static memory pool (PC_ENABLE_TLS).
Layer 7 (Application) - embedded web assets generated from web_assets/input/.
WebDAV server core (RFC 4918): method classification, header parsing, and the 207 Multi-Status XML bu...
void ws_free(uint8_t slot_id)
Free the WsConn associated with a TCP slot.
Definition websocket.cpp:79
void ws_feed_byte(WsConn *ws, uint8_t byte)
Feed one already-plaintext byte through the WS frame state machine.
void ws_close(WsConn *ws, WsCloseCode code)
Send a Close frame and mark the slot WsParseState::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:67
void ws_reset_frame(WsConn *ws)
Reset the frame parser back to WsParseState::WS_HEADER1, ready for the next frame.
void ws_init()
Initialize all WebSocket pool slots to inactive.
Definition websocket.cpp:31
@ WS_FRAME_READY
Complete frame ready for dispatch.
@ WS_ERROR
Protocol error; close frame has been queued.
@ WS_CLOSED
Connection closed; slot may be recycled.
void pc_worker_run_deferred(int worker_id)
Drain and run worker worker_id's deferred callbacks (called by the worker).
Definition worker.cpp:174
void pc_workers_start(pc_worker_pump_fn pump)
Spawn the worker task(s) and start them running pump. No-op on host.
Definition worker.cpp:118
bool pc_workers_running(void)
True while worker task(s) are running (always false on host).
Definition worker.cpp:202
bool pc_defer(int worker_id, pc_deferred_fn fn, void *arg)
Run fn(arg) on worker worker_id. Returns false if the queue is full.
Definition worker.cpp:142
void pc_workers_stop(void)
Signal the worker task(s) to exit and wait briefly for them. No-op on host.
Definition worker.cpp:190
Layer 5 (Session) - server worker identity.
void(* pc_deferred_fn)(void *arg)
Deferred callback signature.
Definition worker.h:107