ProtoCore v1.0.16
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
websocket.h
Go to the documentation of this file.
1// ProtoCore v1.0.16 - Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file websocket.h
6 * @brief Layer 6 (Presentation) -- WebSocket frame parser and connection pool.
7 *
8 * Implements RFC 6455 framing with a fixed-size payload buffer per slot.
9 * Connections are tracked in ws_pool[MAX_WS_CONNS]; each entry maps to one
10 * TCP slot in conn_pool[] via slot_id.
11 *
12 * **Frame format (client to server)**
13 * ```
14 * 0 1 2 3
15 * 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
16 * +-+-+-+-+-------+-+-------------+-------------------------------+
17 * |F|R|R|R| opcode|M| payload len | extended payload length |
18 * |I|S|S|S| (4) |A| (7) | (16/64) |
19 * |N|V|V|V| |S| +-------------------------------+
20 * | |1|2|3| |K| | |
21 * +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - -+
22 * | extended payload length continued, if payload len == 127 |
23 * + - - - - - - - - - - - - - - -+-------------------------------+
24 * | | masking key, if MASK set |
25 * +-------------------------------+-------------------------------+
26 * | masking key (continued) | payload data |
27 * +-------------------------------- - - - - - - - - - - - - - - -+
28 * : payload data continued :
29 * +---------------------------------------------------------------+
30 * ```
31 *
32 * **State machine**
33 * ```
34 * WS_HEADER1 -- read FIN + opcode byte
35 * WS_HEADER2 -- read MASK + 7-bit payload length
36 * WS_LEN16_HI -- read extended 16-bit length high byte
37 * WS_LEN16_LO -- read extended 16-bit length low byte
38 * WS_LEN64 -- consume 8-byte 64-bit length (reject; too large)
39 * WS_MASK0..3 -- read 4-byte masking key
40 * WS_PAYLOAD -- accumulate payload bytes (unmasked)
41 * WS_FRAME_READY -- complete frame waiting for dispatch
42 * WS_CLOSED -- connection closed; slot may be recycled
43 * WS_ERROR -- protocol error; close frame sent
44 * ```
45 *
46 * **Limitations**
47 * - A reassembled message must fit in WS_FRAME_SIZE bytes; larger closes 1009.
48 * - RSV bits must be zero (no extensions supported).
49 *
50 * **Fragmentation (RFC 6455 §5.4)**
51 * Fragmented data messages are reassembled into `buf` across continuation
52 * frames; the message is delivered only when the FIN frame arrives. Control
53 * frames (ping/pong/close) may be interleaved between fragments and are
54 * handled immediately without disturbing the partial message.
55 *
56 * @author Douglas Quigg (dstroy0)
57 * @date 2026
58 */
59
60#ifndef PROTOCORE_WEBSOCKET_H
61#define PROTOCORE_WEBSOCKET_H
62
63#include "protocore_config.h"
64
65#if PROTOCORE_ENABLE_WEBSOCKET
66
68
69#if PROTOCORE_ENABLE_WS_DEFLATE
72
73/**
74 * @brief Scratch borrowed while compressing one outbound message.
75 *
76 * The deflate state and the output buffer are live together. PROTOCORE_WS_DEFLATE_MAX bounds the payload
77 * the compressor will accept, which is what turns `len + len/8 + 16` into a constant.
78 */
79#define PROTOCORE_PLAINTEXT_WORK_WS_SEND \
80 (DEFLATE_SCRATCH_SIZE + PROTOCORE_WS_DEFLATE_MAX + (PROTOCORE_WS_DEFLATE_MAX / 8) + 16)
81
82/**
83 * @brief Scratch borrowed while decompressing one reassembled inbound message.
84 *
85 * Input (message + the RFC 7692 00 00 ff ff marker), output, and the inflate tables are live
86 * together. The parser closes 1009 before a message exceeds WS_FRAME_SIZE, which bounds the input.
87 */
88#define PROTOCORE_PLAINTEXT_WORK_WS_RECV (WS_FRAME_SIZE + 4 + WS_FRAME_SIZE + INFLATE_SCRATCH_SIZE)
89#else
90#define PROTOCORE_PLAINTEXT_WORK_WS_SEND 0
91#define PROTOCORE_PLAINTEXT_WORK_WS_RECV 0
92#endif
93
94// ---------------------------------------------------------------------------
95// WebSocket opcodes (RFC 6455 §5.2)
96// ---------------------------------------------------------------------------
97
98/** @brief WebSocket frame opcodes. */
99typedef enum PROTO_ENUM_PACKED
100{
101 WS_OP_CONTINUATION = 0x0, ///< Continuation frame (data-message fragment; reassembled into buf).
102 WS_OP_TEXT = 0x1, ///< UTF-8 text payload.
103 WS_OP_BINARY = 0x2, ///< Binary payload.
104 WS_OP_CLOSE = 0x8, ///< Connection close.
105 WS_OP_PING = 0x9, ///< Ping (auto-ponged by the library).
106 WS_OP_PONG = 0xA ///< Pong (echoed ping; ignored by library).
107} WsOpcode;
108
109/** @brief WebSocket close status codes (RFC 6455 §7.4.1). */
110typedef enum PROTO_ENUM_PACKED
111{
112 WS_CLOSE_NORMAL = 1000, ///< Normal closure.
113 WS_CLOSE_GOING_AWAY = 1001, ///< Endpoint going away.
114 WS_CLOSE_PROTOCOL = 1002, ///< Protocol error.
115 WS_CLOSE_UNSUPPORTED = 1003, ///< Received a data type the endpoint cannot accept (RFC 6455).
116 WS_CLOSE_INVALID_PAYLOAD = 1007, ///< Text message that is not valid UTF-8 (RFC 6455 8.1).
117 WS_CLOSE_TOO_BIG = 1009 ///< Payload too large for WS_FRAME_SIZE.
118} WsCloseCode;
119
120// ---------------------------------------------------------------------------
121// Frame parser states
122// ---------------------------------------------------------------------------
123
124/** @brief States of the WebSocket frame parser. */
125typedef enum PROTO_ENUM_PACKED
126{
127 WS_HEADER1, ///< Awaiting first header byte (FIN, RSV, opcode).
128 WS_HEADER2, ///< Awaiting second header byte (MASK, 7-bit length).
129 WS_LEN16_HI, ///< Reading extended 16-bit length, high byte.
130 WS_LEN16_LO, ///< Reading extended 16-bit length, low byte.
131 WS_LEN64, ///< Consuming 8-byte 64-bit length (always rejected).
132 WS_MASK0, ///< Reading masking key byte 0.
133 WS_MASK1, ///< Reading masking key byte 1.
134 WS_MASK2, ///< Reading masking key byte 2.
135 WS_MASK3, ///< Reading masking key byte 3.
136 WS_PAYLOAD, ///< Accumulating payload bytes.
137 WS_FRAME_READY, ///< Complete frame ready for dispatch.
138 WS_CLOSED, ///< Connection closed; slot may be recycled.
139 WS_ERROR ///< Protocol error; close frame has been queued.
140} WsParseState;
141
142// ---------------------------------------------------------------------------
143// Per-connection WebSocket state
144// ---------------------------------------------------------------------------
145
146/**
147 * @brief WebSocket connection state stored in ws_pool[].
148 *
149 * Allocated when an HTTP upgrade handshake succeeds. slot_id ties this
150 * entry back to conn_pool[] and the ring buffer.
151 */
152typedef struct
153{
154 uint8_t ws_id; ///< Index into ws_pool[] (set at init).
155 uint8_t slot_id; ///< Owning TCP slot in conn_pool[].
156 uint8_t route_id; ///< The handler set this channel was opened for.
157 proto_bool active; ///< True when this entry is in use.
158
159 WsParseState parse_state; ///< Current frame parser state.
160 WsOpcode opcode; ///< Opcode of the frame being parsed.
161 proto_bool fin; ///< FIN bit of the frame being parsed.
162 proto_bool masked; ///< True if client sent a masking key.
163
164 uint8_t mask_key[4]; ///< Client masking key.
165 uint32_t payload_len; ///< Expected payload byte count (current frame).
166 uint32_t payload_idx; ///< Bytes received so far (current frame).
167 uint8_t len64_count; ///< Bytes consumed from 64-bit length.
168 uint8_t buf[WS_FRAME_SIZE + 1]; ///< Reassembled message payload, null-terminated.
169
170 // Fragmentation state (RFC 6455 §5.4). A data message may span multiple
171 // frames (first text/binary with FIN=0, then continuation frames). Control
172 // frames may be interleaved and use a separate buffer so they never clobber
173 // the partially-assembled data message.
174 proto_bool fragmenting; ///< True between a non-FIN data frame and its FIN.
175 WsOpcode msg_opcode; ///< Opcode of the data message being assembled.
176 uint32_t msg_len; ///< Bytes assembled so far across all fragments.
177 uint8_t ctl_buf[125 + 1]; ///< Control-frame payload (ping/pong/close), null-terminated.
178
179#if PROTOCORE_ENABLE_WS_DEFLATE
180 proto_bool pmd; ///< permessage-deflate negotiated on this connection (RFC 7692).
181 proto_bool msg_compressed; ///< Current data message arrived compressed (RSV1 on its first frame).
182#endif
183} WsConn;
184
185/** @brief Pool of WebSocket connection state, one per MAX_WS_CONNS. */
186extern WsConn ws_pool[MAX_WS_CONNS];
187
188// ---------------------------------------------------------------------------
189// WebSocket API
190// ---------------------------------------------------------------------------
191
192/**
193 * @brief Callback fired when a WebSocket connection is established.
194 *
195 * @param ws_id Index into ws_pool[] for this connection.
196 */
197typedef void (*WsConnectHandler)(uint8_t ws_id);
198
199/**
200 * @brief Callback fired when a WebSocket text or binary frame arrives.
201 *
202 * The payload is in ws_pool[ws_id].buf, null-terminated. Length is in
203 * ws_pool[ws_id].payload_len. Opcode is in ws_pool[ws_id].opcode.
204 *
205 * @param ws_id Index into ws_pool[].
206 */
207typedef void (*WsMessageHandler)(uint8_t ws_id);
208
209/**
210 * @brief Callback fired when a WebSocket connection closes.
211 *
212 * @param ws_id Index into ws_pool[] (slot is still valid during callback).
213 */
214typedef void (*WsCloseHandler)(uint8_t ws_id);
215
216/** @brief The id a route carries when it serves no WebSocket. */
217#define PROTOCORE_WS_NONE 0xFFu
218
219/** @brief The three handlers one route records. */
220typedef struct
221{
222 WsConnectHandler on_connect; ///< the handler recorded for a route's open
223 WsMessageHandler on_message; ///< the handler recorded for a message
224 WsCloseHandler on_close; ///< the handler recorded for a close
225} WsRouteArgs;
226
227/** @brief RFC 6455 sec 5.2 base framing, and the sec 7.4 status a Close carries. */
228typedef struct
229{
230 WsOpcode opcode; ///< the frame type a send emits
231 const uint8_t *payload; ///< its payload bytes; may be NULL for a zero-length frame
232 uint16_t len; ///< how many
233 WsCloseCode code; ///< the status code a close carries
234} WsFrameArgs;
235
236/**
237 * @brief The WebSocket connections this server holds open (RFC 6455).
238 *
239 * A caller sets the members a call takes, invokes it through ::Ws, and reads the outcome off the
240 * same handle.
241 *
242 * The handlers live here, not in the route table: a route decides where a request goes, and what
243 * runs once the socket is open belongs to this module. A route stores the id, so nothing above
244 * holds a pointer into here and the same handler set can serve more than one route.
245 *
246 * @var WsNs::slot the TCP slot a call acts on
247 * @var WsNs::ws_id the socket a call names
248 * @var WsNs::id the route id a lookup names
249 * @var WsNs::route the handlers one route records
250 * @var WsNs::conn the socket a call acts on, when it takes one by pointer
251 * @var WsNs::frame one frame's type, payload and close status (RFC 6455 sec 5.2, 7.4)
252 * @var WsNs::byte one already-plaintext byte for the frame state machine
253 * @var WsNs::frag_size the outbound fragmentation size in payload bytes; 0 = off
254 * @var WsNs::ok a call's true/false outcome
255 * @var WsNs::u8 the route id an add reports, or ::PROTOCORE_WS_NONE when full
256 * @var WsNs::text the reassembled message payload a lookup reports, or NULL
257 * @var WsNs::found the socket an alloc or a find reports, or NULL
258 * @var WsNs::connect_handler the connect handler an id names, or NULL
259 * @var WsNs::message_handler the message handler an id names, or NULL
260 * @var WsNs::close_handler the close handler an id names, or NULL
261 * @var WsNs::route_add record one route's handlers
262 * @var WsNs::route_reset empty the handler table; a route holds the id an add returned, so
263 * this empties with the routes
264 * @var WsNs::route_connect the connect handler an id names
265 * @var WsNs::route_message the message handler an id names
266 * @var WsNs::route_close the close handler an id names
267 * @var WsNs::init set every pool slot inactive; called once from begin()
268 * @var WsNs::active whether ws_id is a valid, in-use socket
269 * @var WsNs::payload_of the reassembled message payload for ws_id
270 * @var WsNs::alloc take a socket and bind it to a TCP slot
271 * @var WsNs::find the socket bound to a TCP slot
272 * @var WsNs::free release the socket bound to a TCP slot
273 * @var WsNs::parse feed the slot's bytes through the frame state machine
274 * @var WsNs::feed_byte feed one already-plaintext byte through it
275 * @var WsNs::reset_frame back to WS_HEADER1, ready for the next frame
276 * @var WsNs::send_frame build and send one frame; server-to-client frames are never masked
277 * @var WsNs::set_frag_size the outbound fragmentation size (RFC 6455 sec 5.4)
278 * @var WsNs::close send a Close frame and mark the socket WS_CLOSED
279 *
280 * Every entry takes the module's borrow. How those bytes are carved is websocket.c's and is never
281 * named here. ::protocore_ws_span is where a caller gets one.
282 *
283 * A caller that needs immediate delivery flushes the connection itself after a send.
284 */
285typedef struct
286{
287 uint8_t slot; ///< the TCP slot a call acts on
288 uint8_t ws_id; ///< the socket a call names
289 uint8_t id; ///< the route id a lookup names
290 WsConn *conn; ///< the socket a call acts on, when it takes one by pointer
291 uint8_t byte; ///< one already-plaintext byte for the frame state machine
292 uint16_t frag_size; ///< the outbound fragmentation size in payload bytes; 0 = off
293#if PROTOCORE_ENABLE_WS_DEFLATE
294 proto_bool pmd; ///< what an alloc records on the new connection: RFC 7692 permessage-deflate,
295 ///< as the handshake negotiated it. The layer that read the Sec-WebSocket-
296 ///< Extensions header is the only one that knows, so it states it here.
297#endif
298 WsRouteArgs route; ///< the handlers one route records
299 WsFrameArgs frame; ///< one frame's type, payload and close status
300 proto_bool ok;
301 uint8_t u8;
302 const char *text;
303 WsConn *found;
304 WsConnectHandler connect_handler;
305 WsMessageHandler message_handler;
306 WsCloseHandler close_handler;
307} WsVars;
308
309/** @brief The operands and the outcome. */
310extern WsVars WsV;
311
312/** @brief The entries. */
313typedef struct
314{
315 void (*const route_add)(uint8_t *work);
316 void (*const route_reset)(uint8_t *work);
317 void (*const route_connect)(uint8_t *work);
318 void (*const route_message)(uint8_t *work);
319 void (*const route_close)(uint8_t *work);
320 void (*const init)(uint8_t *work);
321 void (*const active)(uint8_t *work);
322 void (*const payload_of)(uint8_t *work);
323 void (*const alloc)(uint8_t *work);
324 void (*const find)(uint8_t *work);
325 void (*const free)(uint8_t *work);
326 void (*const parse)(uint8_t *work);
327 void (*const feed_byte)(uint8_t *work);
328 void (*const reset_frame)(uint8_t *work);
329 void (*const send_frame)(uint8_t *work);
330 void (*const set_frag_size)(uint8_t *work);
331 void (*const close)(uint8_t *work);
332} WsNs;
333
334// What the table binds, defined once in the .c and taking one parameter each: everything
335// else an entry needs is an operand in WsV or a region of the borrow at a fixed offset.
336void protocore_ws_route_add(uint8_t *work);
337void protocore_ws_route_reset(uint8_t *work);
338void protocore_ws_route_connect(uint8_t *work);
339void protocore_ws_route_message(uint8_t *work);
340void protocore_ws_route_close(uint8_t *work);
341void protocore_ws_init(uint8_t *work);
342void protocore_ws_active(uint8_t *work);
343void protocore_ws_payload_of(uint8_t *work);
344void protocore_ws_alloc(uint8_t *work);
345void protocore_ws_find(uint8_t *work);
346void protocore_ws_free(uint8_t *work);
347void protocore_ws_parse(uint8_t *work);
348void protocore_ws_feed_byte(uint8_t *work);
349void protocore_ws_reset_frame(uint8_t *work);
350void protocore_ws_send_frame(uint8_t *work);
351void protocore_ws_set_frag_size(uint8_t *work);
352void protocore_ws_close(uint8_t *work);
353
354// `static const`, initialised HERE rather than `extern` against a definition in the .c: a
355// const object whose initializer every translation unit can see is a COMPILE-TIME FACT, so
356// `Ws.route_add(work)` resolves to a named function and becomes a DIRECT call. An extern table
357// leaves the call indirect and the symbol live at every level, -O2 -flto included.
358static const WsNs Ws __attribute__((unused)) = {
359 .route_add = protocore_ws_route_add,
360 .route_reset = protocore_ws_route_reset,
361 .route_connect = protocore_ws_route_connect,
362 .route_message = protocore_ws_route_message,
363 .route_close = protocore_ws_route_close,
364 .init = protocore_ws_init,
365 .active = protocore_ws_active,
366 .payload_of = protocore_ws_payload_of,
367 .alloc = protocore_ws_alloc,
368 .find = protocore_ws_find,
369 .free = protocore_ws_free,
370 .parse = protocore_ws_parse,
371 .feed_byte = protocore_ws_feed_byte,
372 .reset_frame = protocore_ws_reset_frame,
373 .send_frame = protocore_ws_send_frame,
374 .set_frag_size = protocore_ws_set_frag_size,
375 .close = protocore_ws_close,
376};
377
378/** @brief Not an entry: an entry takes a borrow and this is where that borrow comes from. */
379uint8_t *protocore_ws_span(void);
380
382
383#endif // PROTOCORE_ENABLE_WEBSOCKET
384
385#endif
#define WS_FRAME_SIZE
Maximum WebSocket frame payload in bytes.
PROTO_ENUM_PACKED
Application protocol spoken on a listener port or connection slot.
#define MAX_WS_CONNS
Maximum simultaneous WebSocket connections.
Bounded RFC 1951 DEFLATE decompressor (INFLATE) - no heap.
Bounded RFC 1951 DEFLATE compressor (DEFLATE) - no heap.
#define PROTOCORE_BEGIN_DECLS
Give a header's declarations C linkage, so their symbol names carry no parameter types.
Definition types.h:96
_Bool proto_bool
The truth value.
Definition types.h:64
#define PROTOCORE_END_DECLS
Definition types.h:97