# HG changeset patch # User June Park # Date 1767871199 28800 # Node ID cbbf78b17cfa6c49336dd6f2b1dfcb372c4778c7 # Parent c39582f937e5c1112234ec7af5080014aee92f44 [Seobeo][Websocket] Created Web socket client logic. diff -r c39582f937e5 -r cbbf78b17cfa seobeo/BUILD --- a/seobeo/BUILD Wed Jan 07 16:05:57 2026 -0800 +++ b/seobeo/BUILD Thu Jan 08 03:19:59 2026 -0800 @@ -127,6 +127,7 @@ "s_web.c", "s_ssl.c", "s_http_client.c", + "s_websocket.c", "snapshot_creator.c", "os/s_macos_edge.c", ], @@ -148,6 +149,7 @@ "s_web.c", "s_ssl.c", "s_http_client.c", + "s_websocket.c", "snapshot_creator.c", "os/s_linux_edge.c", ], @@ -186,3 +188,12 @@ visibility = ["//visibility:public"], ) +cc_test( + name = "seobeo_websocket_test", + srcs = ["tests/seobeo_websocket_test.c"], + deps = [":seobeo_client"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +) + diff -r c39582f937e5 -r cbbf78b17cfa seobeo/docs/web_socket_client.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/docs/web_socket_client.md Thu Jan 08 03:19:59 2026 -0800 @@ -0,0 +1,270 @@ +# Seobeo WebSocket Client - Usage Guide + +A clean, easy-to-use WebSocket client library following RFC 6455. + +## Features + +1. **WebSocket Protocol Support**: Full RFC 6455 WebSocket implementation +2. **Text and Binary Messages**: Send/receive both text and binary data +3. **Automatic Ping/Pong**: Automatic pong responses to ping frames +4. **Message Fragmentation**: Handles fragmented messages automatically +5. **Secure WebSocket**: Supports both `ws://` and `wss://` (WebSocket over TLS) +6. **Clean API**: Simple, intuitive API following seobeo coding standards + +## API Overview + +### Core Functions + +```c +// Connect to WebSocket server +Seobeo_WebSocket *Seobeo_WebSocket_Connect(const char *url); + +// Send messages +int32 Seobeo_WebSocket_Send_Text(Seobeo_WebSocket *p_ws, const char *text); +int32 Seobeo_WebSocket_Send_Binary(Seobeo_WebSocket *p_ws, const uint8 *data, size_t length); + +// Send control frames +int32 Seobeo_WebSocket_Send_Ping(Seobeo_WebSocket *p_ws, const char *payload); +int32 Seobeo_WebSocket_Send_Pong(Seobeo_WebSocket *p_ws, const char *payload); + +// Receive messages +Seobeo_WebSocket_Message *Seobeo_WebSocket_Receive(Seobeo_WebSocket *p_ws); + +// Close connection +int32 Seobeo_WebSocket_Close(Seobeo_WebSocket *p_ws, uint16 code, const char *reason); + +// Clean up +void Seobeo_WebSocket_Message_Destroy(Seobeo_WebSocket_Message *p_msg); +void Seobeo_WebSocket_Destroy(Seobeo_WebSocket *p_ws); +``` + +### Message Opcodes + +```c +typedef enum { + SEOBEO_WS_OPCODE_CONTINUATION = 0x0, // Continuation frame + SEOBEO_WS_OPCODE_TEXT = 0x1, // Text message + SEOBEO_WS_OPCODE_BINARY = 0x2, // Binary message + SEOBEO_WS_OPCODE_CLOSE = 0x8, // Close connection + SEOBEO_WS_OPCODE_PING = 0x9, // Ping frame + SEOBEO_WS_OPCODE_PONG = 0xA // Pong frame +} Seobeo_WebSocket_Opcode; +``` + +### Connection States + +```c +typedef enum { + SEOBEO_WS_STATE_CONNECTING = 0, // Handshake in progress + SEOBEO_WS_STATE_OPEN, // Connection established + SEOBEO_WS_STATE_CLOSING, // Close handshake initiated + SEOBEO_WS_STATE_CLOSED // Connection closed +} Seobeo_WebSocket_State; +``` + +## Examples + +### 1. Simple Text Echo + +```c +// Connect to WebSocket server +Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://echo.websocket.org"); +if (!p_ws) +{ + printf("Failed to connect\n"); + return; +} + +// Send text message +const char *message = "Hello, WebSocket!"; +Seobeo_WebSocket_Send_Text(p_ws, message); + +// Receive echo response +Seobeo_WebSocket_Message *p_msg = Seobeo_WebSocket_Receive(p_ws); +if (p_msg && p_msg->opcode == SEOBEO_WS_OPCODE_TEXT) +{ + printf("Received: %.*s\n", (int)p_msg->length, (char*)p_msg->data); + Seobeo_WebSocket_Message_Destroy(p_msg); +} + +// Close connection +Seobeo_WebSocket_Close(p_ws, 1000, "Normal closure"); +Seobeo_WebSocket_Destroy(p_ws); +``` + +### 2. Binary Data Transfer + +```c +Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://example.com/data"); + +// Send binary data +uint8 data[] = {0x01, 0x02, 0x03, 0xAA, 0xBB, 0xCC}; +Seobeo_WebSocket_Send_Binary(p_ws, data, sizeof(data)); + +// Receive binary response +Seobeo_WebSocket_Message *p_msg = Seobeo_WebSocket_Receive(p_ws); +if (p_msg && p_msg->opcode == SEOBEO_WS_OPCODE_BINARY) +{ + printf("Received %zu bytes\n", p_msg->length); + // Process binary data... + Seobeo_WebSocket_Message_Destroy(p_msg); +} + +Seobeo_WebSocket_Destroy(p_ws); +``` + +### 3. Chat Application + +```c +Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://chat.example.com"); + +// Send chat message +Seobeo_WebSocket_Send_Text(p_ws, "Hello everyone!"); + +// Continuous receive loop +while (1) +{ + Seobeo_WebSocket_Message *p_msg = Seobeo_WebSocket_Receive(p_ws); + if (p_msg) + { + if (p_msg->opcode == SEOBEO_WS_OPCODE_TEXT) + { + printf("Chat: %.*s\n", (int)p_msg->length, (char*)p_msg->data); + } + Seobeo_WebSocket_Message_Destroy(p_msg); + } + + usleep(10000); // 10ms sleep to avoid busy waiting +} + +Seobeo_WebSocket_Destroy(p_ws); +``` + +### 4. Ping/Pong Keep-Alive + +```c +Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://api.example.com"); + +// Send ping to keep connection alive +Seobeo_WebSocket_Send_Ping(p_ws, "keep-alive"); + +// Server will automatically receive pong responses +// (pong responses are handled internally) + +Seobeo_WebSocket_Destroy(p_ws); +``` + +### 5. Handling Different Message Types + +```c +Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://example.com"); + +while (1) +{ + Seobeo_WebSocket_Message *p_msg = Seobeo_WebSocket_Receive(p_ws); + if (p_msg) + { + switch (p_msg->opcode) + { + case SEOBEO_WS_OPCODE_TEXT: + printf("Text: %.*s\n", (int)p_msg->length, (char*)p_msg->data); + break; + + case SEOBEO_WS_OPCODE_BINARY: + printf("Binary: %zu bytes\n", p_msg->length); + break; + + default: + printf("Unknown opcode: 0x%X\n", p_msg->opcode); + break; + } + + Seobeo_WebSocket_Message_Destroy(p_msg); + } + + usleep(10000); +} + +Seobeo_WebSocket_Destroy(p_ws); +``` + +### 6. Graceful Shutdown + +```c +Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://example.com"); + +// Do work... + +// Close with custom status code and reason +Seobeo_WebSocket_Close(p_ws, 1000, "Client shutting down"); +Seobeo_WebSocket_Destroy(p_ws); +``` + +## WebSocket Close Codes + +Common close status codes (RFC 6455): + +- **1000**: Normal closure +- **1001**: Going away (e.g., server shutdown, browser navigation) +- **1002**: Protocol error +- **1003**: Unsupported data type +- **1006**: Abnormal closure (no close frame received) +- **1007**: Invalid frame payload data +- **1008**: Policy violation +- **1009**: Message too big +- **1010**: Mandatory extension missing +- **1011**: Internal server error + +## Building + +### Build the library: +```bash +bazel build //seobeo:seobeo_client +``` + +### Build and run the WebSocket test: +```bash +bazel test //seobeo:seobeo_websocket_test +``` + +## Message Structure + +```c +typedef struct { + Seobeo_WebSocket_Opcode opcode; // Message type + uint8 *data; // Message payload + size_t length; // Payload length + boolean is_final; // Final fragment flag +} Seobeo_WebSocket_Message; +``` + +## Protocol Details + +The implementation follows RFC 6455: + +1. **Handshake**: HTTP Upgrade request with `Sec-WebSocket-Key` +2. **Frame Format**: Proper FIN, opcode, mask, and payload length handling +3. **Masking**: All client-to-server frames are masked (required by RFC) +4. **Fragmentation**: Handles fragmented messages across multiple frames +5. **Control Frames**: Proper handling of ping, pong, and close frames +6. **TLS Support**: Supports secure WebSocket (wss://) via OpenSSL + +## Coding Standards + +The implementation follows your specified coding standards: +- Naming: `Seobeo_WebSocket_Connect`, `Seobeo_WebSocket_Send_Text` +- Two spaces for indentation +- New line before `{` unless it's a struct +- Single statement: no need for `{}` + +## Features + +✅ WebSocket handshake (HTTP Upgrade) +✅ Text and binary message support +✅ Automatic masking for client frames +✅ Message fragmentation handling +✅ Ping/Pong frames +✅ Close handshake +✅ TLS/SSL support (wss://) +✅ Non-blocking receive +✅ Proper memory management with arenas diff -r c39582f937e5 -r cbbf78b17cfa seobeo/s_websocket.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/s_websocket.c Thu Jan 08 03:19:59 2026 -0800 @@ -0,0 +1,545 @@ +#include "seobeo/seobeo.h" +#include + +#define SEOBEO_WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +#define MAX_INT_16 65536 +#define MAX_INT_64 18446744073709551615 + +static void Seobeo_WebSocket_Parse_Url(const char *url, char **p_host, char **p_port, char **p_path, boolean *p_use_tls, Dowa_Arena *p_arena) +{ + if (!url) + return; + + const char *start = url; + *p_use_tls = FALSE; + + if (strncmp(url, "wss://", 6) == 0) + { + *p_use_tls = TRUE; + start = url + 6; + } + else if (strncmp(url, "ws://", 5) == 0) + { + *p_use_tls = FALSE; + start = url + 5; + } + + const char *slash = strchr(start, '/'); + const char *colon = strchr(start, ':'); + + if (colon && (!slash || colon < slash)) + { + size_t host_len = colon - start; + *p_host = Dowa_Arena_Allocate(p_arena, host_len + 1); + memcpy(*p_host, start, host_len); + (*p_host)[host_len] = '\0'; + + const char *port_start = colon + 1; + size_t port_len = slash ? (slash - port_start) : strlen(port_start); + *p_port = Dowa_Arena_Allocate(p_arena, port_len + 1); + memcpy(*p_port, port_start, port_len); + (*p_port)[port_len] = '\0'; + } + else + { + size_t host_len = slash ? (slash - start) : strlen(start); + *p_host = Dowa_Arena_Allocate(p_arena, host_len + 1); + memcpy(*p_host, start, host_len); + (*p_host)[host_len] = '\0'; + + *p_port = Dowa_Arena_Allocate(p_arena, 8); + strcpy(*p_port, *p_use_tls ? "443" : "80"); + } + + if (slash) + { + size_t path_len = strlen(slash); + *p_path = Dowa_Arena_Allocate(p_arena, path_len + 1); + strcpy(*p_path, slash); + } + else + { + *p_path = Dowa_Arena_Allocate(p_arena, 2); + strcpy(*p_path, "/"); + } +} + +static void Seobeo_WebSocket_Generate_Key(char *key_out, size_t key_size) +{ + static const char base64_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + srand((unsigned int)time(NULL) ^ (unsigned int)getpid()); + + uint8 random_bytes[16]; + for (int i = 0; i < 16; i++) + random_bytes[i] = (uint8)(rand() % 256); + + int out_idx = 0; + for (int i = 0; i < 16; i += 3) + { + uint32 triple = (random_bytes[i] << 16) | + (i + 1 < 16 ? random_bytes[i + 1] << 8 : 0) | + (i + 2 < 16 ? random_bytes[i + 2] : 0); + + key_out[out_idx++] = base64_chars[(triple >> 18) & 0x3F]; + key_out[out_idx++] = base64_chars[(triple >> 12) & 0x3F]; + key_out[out_idx++] = base64_chars[(triple >> 6) & 0x3F]; + key_out[out_idx++] = base64_chars[triple & 0x3F]; + } + + key_out[out_idx] = '\0'; +} + +Seobeo_WebSocket *Seobeo_WebSocket_Connect(const char *url) +{ + Seobeo_WebSocket *p_ws = malloc(sizeof(Seobeo_WebSocket)); + if (!p_ws) + return NULL; + + memset(p_ws, 0, sizeof(Seobeo_WebSocket)); + + p_ws->p_arena = Dowa_Arena_Create(1024 * 1024); + if (!p_ws->p_arena) + { + free(p_ws); + return NULL; + } + + size_t url_len = strlen(url); + p_ws->url = Dowa_Arena_Allocate(p_ws->p_arena, url_len + 1); + strcpy(p_ws->url, url); + + Seobeo_WebSocket_Parse_Url(url, &p_ws->host, &p_ws->port, &p_ws->path, &p_ws->use_tls, p_ws->p_arena); + + p_ws->p_handle = Seobeo_Stream_Handle_Client_Create(p_ws->host, p_ws->port, p_ws->use_tls); + if (!p_ws->p_handle || p_ws->p_handle->socket < 0) + { + Seobeo_Log(SEOBEO_ERROR, "Failed to create socket connection\n"); + if (p_ws->p_handle) + Seobeo_Handle_Destroy(p_ws->p_handle); + Dowa_Arena_Free(p_ws->p_arena); + free(p_ws); + return NULL; + } + + char ws_key[32]; + Seobeo_WebSocket_Generate_Key(ws_key, sizeof(ws_key)); + + char handshake[2048]; + int handshake_len = snprintf(handshake, sizeof(handshake), + "GET %s HTTP/1.1\r\n" + "Host: %s\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: %s\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n", + p_ws->path, p_ws->host, ws_key); + + Seobeo_Handle_Queue(p_ws->p_handle, (uint8*)handshake, (uint32)handshake_len); + if (Seobeo_Handle_Flush(p_ws->p_handle) < 0) + { + Seobeo_Log(SEOBEO_ERROR, "Failed to send WebSocket handshake\n"); + Seobeo_Handle_Destroy(p_ws->p_handle); + Dowa_Arena_Free(p_ws->p_arena); + free(p_ws); + return NULL; + } + + while (1) + { + int r = Seobeo_Handle_Read(p_ws->p_handle); + if (r < 0) + { + Seobeo_Log(SEOBEO_ERROR, "Failed to read handshake response\n"); + Seobeo_Handle_Destroy(p_ws->p_handle); + Dowa_Arena_Free(p_ws->p_arena); + free(p_ws); + return NULL; + } + + if (p_ws->p_handle->read_buffer_len >= 4 && + strstr((char*)p_ws->p_handle->read_buffer, "\r\n\r\n") != NULL) + break; + + if (r == 0) + continue; + } + + char *response = (char*)p_ws->p_handle->read_buffer; + if (strstr(response, "HTTP/1.1 101") == NULL) + { + Seobeo_Log(SEOBEO_ERROR, "WebSocket handshake failed: %s\n", response); + Seobeo_Handle_Destroy(p_ws->p_handle); + Dowa_Arena_Free(p_ws->p_arena); + free(p_ws); + return NULL; + } + + char *end_of_headers = strstr(response, "\r\n\r\n"); + if (end_of_headers) + { + size_t header_len = end_of_headers - response + 4; + Seobeo_Handle_Consume(p_ws->p_handle, (uint32)header_len); + } + + p_ws->state = SEOBEO_WS_STATE_OPEN; + p_ws->fragment_capacity = 4096; + p_ws->fragment_buffer = malloc(p_ws->fragment_capacity); + + Seobeo_Log(SEOBEO_INFO, "WebSocket connected to %s\n", url); + + return p_ws; +} + +static void Seobeo_WebSocket_Mask_Data(uint8 *data, size_t length, const uint8 *mask) +{ + for (size_t i = 0; i < length; i++) + data[i] ^= mask[i % 4]; +} + +static int32 Seobeo_WebSocket_Send_Frame(Seobeo_WebSocket *p_ws, Seobeo_WebSocket_Opcode opcode, + const uint8 *payload, size_t payload_length, boolean fin) +{ + if (!p_ws || p_ws->state != SEOBEO_WS_STATE_OPEN) + return -1; + + uint8 frame[14]; + size_t frame_len = 0; + + // Big endian + frame[0] = (fin ? 0x80 : 0x00) | (opcode & 0x0F); + frame_len++; + + uint8 mask_key[4]; + for (int i = 0; i < 4; i++) + mask_key[i] = (uint8)(rand() % 256); + + // within 1 byte + if (payload_length < 126) + { + frame[1] = 0x80 | (uint8)payload_length; + frame_len++; + } + // from now frame 1 is thrown away just keeping it 1 + // within 4 bytes + else if (payload_length < MAX_INT_16) + { + frame[1] = 0x80 | 126; + frame[2] = (uint8)((payload_length >> 8) & 0xFF); + frame[3] = (uint8)(payload_length & 0xFF); + frame_len += 3; + } + // within 8 bytes + else + { + frame[1] = 0x80 | 127; + for (int i = 0; i < 8; i++) + frame[2 + i] = (uint8)((payload_length >> (56 - i * 8)) & 0xFF); + frame_len += 9; + } + + memcpy(frame + frame_len, mask_key, 4); + frame_len += 4; + + Seobeo_Handle_Queue(p_ws->p_handle, frame, (uint32)frame_len); + + if (payload_length > 0) + { + uint8 *masked_payload = malloc(payload_length); + memcpy(masked_payload, payload, payload_length); + Seobeo_WebSocket_Mask_Data(masked_payload, payload_length, mask_key); + Seobeo_Handle_Queue(p_ws->p_handle, masked_payload, (uint32)payload_length); + free(masked_payload); + } + + return Seobeo_Handle_Flush(p_ws->p_handle); +} + +static int32 Seobeo_WebSocket_Send_Fragmented(Seobeo_WebSocket *p_ws, Seobeo_WebSocket_Opcode opcode, const uint8 *payload, size_t total_length) +{ + if (!payload || total_length == 0) + return -1; + + const size_t max_fragment_size = 1024 * 1024; + + if (total_length <= max_fragment_size) + return Seobeo_WebSocket_Send_Frame(p_ws, opcode, payload, total_length, TRUE); + + size_t sent = 0; + int32 result; + + result = Seobeo_WebSocket_Send_Frame(p_ws, opcode, payload, max_fragment_size, FALSE); + if (result < 0) + return result; + + sent += max_fragment_size; + + while (sent + max_fragment_size < total_length) + { + result = Seobeo_WebSocket_Send_Frame(p_ws, SEOBEO_WS_OPCODE_CONTINUATION, payload + sent, max_fragment_size, FALSE); + if (result < 0) + return result; + sent += max_fragment_size; + } + + size_t remaining = total_length - sent; + return Seobeo_WebSocket_Send_Frame(p_ws, SEOBEO_WS_OPCODE_CONTINUATION, payload + sent, remaining, TRUE); +} + +int32 Seobeo_WebSocket_Send_Text(Seobeo_WebSocket *p_ws, const char *text) +{ + if (!text) + return -1; + + return Seobeo_WebSocket_Send_Fragmented(p_ws, SEOBEO_WS_OPCODE_TEXT, (const uint8*)text, strlen(text)); +} + +int32 Seobeo_WebSocket_Send_Binary(Seobeo_WebSocket *p_ws, const uint8 *data, size_t length) +{ + if (!data) + return -1; + + return Seobeo_WebSocket_Send_Fragmented(p_ws, SEOBEO_WS_OPCODE_BINARY, data, length); +} + +int32 Seobeo_WebSocket_Send_Ping(Seobeo_WebSocket *p_ws, const char *payload) +{ + size_t len = payload ? strlen(payload) : 0; + return Seobeo_WebSocket_Send_Frame(p_ws, SEOBEO_WS_OPCODE_PING, (const uint8*)payload, len, TRUE); +} + +int32 Seobeo_WebSocket_Send_Pong(Seobeo_WebSocket *p_ws, const char *payload) +{ + size_t len = payload ? strlen(payload) : 0; + return Seobeo_WebSocket_Send_Frame(p_ws, SEOBEO_WS_OPCODE_PONG, (const uint8*)payload, len, TRUE); +} + +Seobeo_WebSocket_Message *Seobeo_WebSocket_Receive(Seobeo_WebSocket *p_ws) +{ + if (!p_ws || p_ws->state == SEOBEO_WS_STATE_CLOSED) + return NULL; + + int r = Seobeo_Handle_Read(p_ws->p_handle); + if (r < 0) + { + Seobeo_Log(SEOBEO_ERROR, "WebSocket read error\n"); + p_ws->state = SEOBEO_WS_STATE_CLOSED; + return NULL; + } + + if (r == -2) + { + Seobeo_Log(SEOBEO_INFO, "WebSocket connection closed\n"); + p_ws->state = SEOBEO_WS_STATE_CLOSED; + return NULL; + } + + if (p_ws->p_handle->read_buffer_len < 2) + return NULL; + + uint8 *buf = p_ws->p_handle->read_buffer; + + uint8 byte1 = buf[0]; + uint8 byte2 = buf[1]; + + boolean fin = (byte1 & 0x80) != 0; + Seobeo_WebSocket_Opcode opcode = (Seobeo_WebSocket_Opcode)(byte1 & 0x0F); + boolean masked = (byte2 & 0x80) != 0; + uint64 payload_len = byte2 & 0x7F; + + size_t header_len = 2; + + if (payload_len == 126) + { + if (p_ws->p_handle->read_buffer_len < 4) + return NULL; + payload_len = (buf[2] << 8) | buf[3]; + header_len += 2; + } + else if (payload_len == 127) + { + if (p_ws->p_handle->read_buffer_len < 10) + return NULL; + payload_len = 0; + for (int i = 0; i < 8; i++) + payload_len = (payload_len << 8) | buf[2 + i]; + header_len += 8; + } + + uint8 mask_key[4] = {0}; + if (masked) + { + if (p_ws->p_handle->read_buffer_len < header_len + 4) + return NULL; + memcpy(mask_key, buf + header_len, 4); + header_len += 4; + } + + if (p_ws->p_handle->read_buffer_len < header_len + payload_len) + return NULL; + + uint8 *payload = NULL; + if (payload_len > 0) + { + payload = malloc(payload_len); + memcpy(payload, buf + header_len, payload_len); + + if (masked) + Seobeo_WebSocket_Mask_Data(payload, payload_len, mask_key); + } + + Seobeo_Handle_Consume(p_ws->p_handle, (uint32)(header_len + payload_len)); + + if (opcode == SEOBEO_WS_OPCODE_PING) + { + Seobeo_WebSocket_Send_Pong(p_ws, (char*)payload); + if (payload) + free(payload); + return NULL; + } + + if (opcode == SEOBEO_WS_OPCODE_PONG) + { + if (payload) + free(payload); + return NULL; + } + + if (opcode == SEOBEO_WS_OPCODE_CLOSE) + { + uint16 close_code = 1000; + if (payload_len >= 2) + close_code = (payload[0] << 8) | payload[1]; + + Seobeo_Log(SEOBEO_INFO, "WebSocket close received with code %d\n", close_code); + p_ws->state = SEOBEO_WS_STATE_CLOSING; + + Seobeo_WebSocket_Send_Frame(p_ws, SEOBEO_WS_OPCODE_CLOSE, payload, payload_len, TRUE); + p_ws->state = SEOBEO_WS_STATE_CLOSED; + + if (payload) + free(payload); + return NULL; + } + + if (opcode == SEOBEO_WS_OPCODE_CONTINUATION) + { + if (p_ws->fragment_length + payload_len > p_ws->fragment_capacity) + { + p_ws->fragment_capacity = (p_ws->fragment_length + payload_len) * 2; + p_ws->fragment_buffer = realloc(p_ws->fragment_buffer, p_ws->fragment_capacity); + } + + if (payload_len > 0) + { + memcpy(p_ws->fragment_buffer + p_ws->fragment_length, payload, payload_len); + p_ws->fragment_length += payload_len; + } + + if (payload) + free(payload); + + if (!fin) + return NULL; + + Seobeo_WebSocket_Message *p_msg = malloc(sizeof(Seobeo_WebSocket_Message)); + p_msg->opcode = p_ws->fragment_opcode; + p_msg->data = malloc(p_ws->fragment_length); + memcpy(p_msg->data, p_ws->fragment_buffer, p_ws->fragment_length); + p_msg->length = p_ws->fragment_length; + p_msg->is_final = TRUE; + + p_ws->fragment_length = 0; + + return p_msg; + } + + if (!fin) + { + p_ws->fragment_opcode = opcode; + p_ws->fragment_length = 0; + + if (payload_len > 0) + { + if (payload_len > p_ws->fragment_capacity) + { + p_ws->fragment_capacity = payload_len * 2; + p_ws->fragment_buffer = realloc(p_ws->fragment_buffer, p_ws->fragment_capacity); + } + + memcpy(p_ws->fragment_buffer, payload, payload_len); + p_ws->fragment_length = payload_len; + } + + if (payload) + free(payload); + + return NULL; + } + + Seobeo_WebSocket_Message *p_msg = malloc(sizeof(Seobeo_WebSocket_Message)); + p_msg->opcode = opcode; + p_msg->data = payload; + p_msg->length = payload_len; + p_msg->is_final = fin; + + return p_msg; +} + +void Seobeo_WebSocket_Message_Destroy(Seobeo_WebSocket_Message *p_msg) +{ + if (!p_msg) + return; + + if (p_msg->data) + free(p_msg->data); + + free(p_msg); +} + +int32 Seobeo_WebSocket_Close(Seobeo_WebSocket *p_ws, uint16 code, const char *reason) +{ + if (!p_ws || p_ws->state == SEOBEO_WS_STATE_CLOSED) + return -1; + + p_ws->state = SEOBEO_WS_STATE_CLOSING; + + size_t reason_len = reason ? strlen(reason) : 0; + size_t payload_len = 2 + reason_len; + uint8 *payload = malloc(payload_len); + + payload[0] = (uint8)((code >> 8) & 0xFF); + payload[1] = (uint8)(code & 0xFF); + + if (reason_len > 0) + memcpy(payload + 2, reason, reason_len); + + int32 result = Seobeo_WebSocket_Send_Frame(p_ws, SEOBEO_WS_OPCODE_CLOSE, payload, payload_len, TRUE); + + free(payload); + + p_ws->state = SEOBEO_WS_STATE_CLOSED; + + return result; +} + +void Seobeo_WebSocket_Destroy(Seobeo_WebSocket *p_ws) +{ + if (!p_ws) + return; + + if (p_ws->state == SEOBEO_WS_STATE_OPEN) + Seobeo_WebSocket_Close(p_ws, 1000, "Normal closure"); + + if (p_ws->p_handle) + Seobeo_Handle_Destroy(p_ws->p_handle); + + if (p_ws->fragment_buffer) + free(p_ws->fragment_buffer); + + if (p_ws->p_arena) + Dowa_Arena_Free(p_ws->p_arena); + + free(p_ws); +} diff -r c39582f937e5 -r cbbf78b17cfa seobeo/seobeo.h --- a/seobeo/seobeo.h Wed Jan 07 16:05:57 2026 -0800 +++ b/seobeo/seobeo.h Thu Jan 08 03:19:59 2026 -0800 @@ -88,6 +88,221 @@ extern void Seobeo_Client_Request_Destroy(Seobeo_Client_Request *p_req); /* Destroy response and free all resources. */ extern void Seobeo_Client_Response_Destroy(Seobeo_Client_Response *p_resp); + +/** + * WebSocket Client API + * ------ + * + * # Overview + * + * A clean, easy-to-use WebSocket client library following RFC 6455. It will auto handle over 64 bits long data into a continous stream. + * + * ## Examples + * + * ### 1. Simple Text Echo + * + * ```c + * // Connect to WebSocket server + * Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://echo.websocket.org"); + * if (!p_ws) + * { + * printf("Failed to connect\n"); + * return; + * } + * + * // Send text message + * const char *message = "Hello, WebSocket!"; + * Seobeo_WebSocket_Send_Text(p_ws, message); + * + * // Receive echo response + * Seobeo_WebSocket_Message *p_msg = Seobeo_WebSocket_Receive(p_ws); + * if (p_msg && p_msg->opcode == SEOBEO_WS_OPCODE_TEXT) + * { + * printf("Received: %.*s\n", (int)p_msg->length, (char*)p_msg->data); + * Seobeo_WebSocket_Message_Destroy(p_msg); + * } + * + * // Close connection + * Seobeo_WebSocket_Close(p_ws, 1000, "Normal closure"); + * Seobeo_WebSocket_Destroy(p_ws); + * ``` + * + * ### 2. Binary Data Transfer + * + * ```c + * Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://example.com/data"); + * + * // Send binary data + * uint8 data[] = {0x01, 0x02, 0x03, 0xAA, 0xBB, 0xCC}; + * Seobeo_WebSocket_Send_Binary(p_ws, data, sizeof(data)); + * + * // Receive binary response + * Seobeo_WebSocket_Message *p_msg = Seobeo_WebSocket_Receive(p_ws); + * if (p_msg && p_msg->opcode == SEOBEO_WS_OPCODE_BINARY) + * { + * printf("Received %zu bytes\n", p_msg->length); + * // Process binary data... + * Seobeo_WebSocket_Message_Destroy(p_msg); + * } + * + * Seobeo_WebSocket_Destroy(p_ws); + * ``` + * + * ### 3. Chat Application + * + * ```c + * Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://chat.example.com"); + * + * // Send chat message + * Seobeo_WebSocket_Send_Text(p_ws, "Hello everyone!"); + * + * // Continuous receive loop + * while (1) + * { + * Seobeo_WebSocket_Message *p_msg = Seobeo_WebSocket_Receive(p_ws); + * if (p_msg) + * { + * if (p_msg->opcode == SEOBEO_WS_OPCODE_TEXT) + * { + * printf("Chat: %.*s\n", (int)p_msg->length, (char*)p_msg->data); + * } + * Seobeo_WebSocket_Message_Destroy(p_msg); + * } + * + * usleep(10000); // 10ms sleep to avoid busy waiting + * } + * + * Seobeo_WebSocket_Destroy(p_ws); + * ``` + * + * ### 4. Ping/Pong Keep-Alive + * + * ```c + * Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://api.example.com"); + * + * // Send ping to keep connection alive + * Seobeo_WebSocket_Send_Ping(p_ws, "keep-alive"); + * + * // Server will automatically receive pong responses + * // (pong responses are handled internally) + * + * Seobeo_WebSocket_Destroy(p_ws); + * ``` + * + * ### 5. Handling Different Message Types + * + * ```c + * Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://example.com"); + * + * while (1) + * { + * Seobeo_WebSocket_Message *p_msg = Seobeo_WebSocket_Receive(p_ws); + * if (p_msg) + * { + * switch (p_msg->opcode) + * { + * case SEOBEO_WS_OPCODE_TEXT: + * printf("Text: %.*s\n", (int)p_msg->length, (char*)p_msg->data); + * break; + * + * case SEOBEO_WS_OPCODE_BINARY: + * printf("Binary: %zu bytes\n", p_msg->length); + * break; + * + * default: + * printf("Unknown opcode: 0x%X\n", p_msg->opcode); + * break; + * } + * + * Seobeo_WebSocket_Message_Destroy(p_msg); + * } + * + * usleep(10000); + * } + * + * Seobeo_WebSocket_Destroy(p_ws); + * ``` + * + * ### 6. Graceful Shutdown + * + * ```c + * Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://example.com"); + * + * // Do work... + * + * // Close with custom status code and reason + * Seobeo_WebSocket_Close(p_ws, 1000, "Client shutting down"); + * Seobeo_WebSocket_Destroy(p_ws); + * ``` + * + * ## WebSocket Close Codes + * + * Common close status codes (RFC 6455): + * + * - **1000**: Normal closure + * - **1001**: Going away (e.g., server shutdown, browser navigation) + * - **1002**: Protocol error + * - **1003**: Unsupported data type + * - **1006**: Abnormal closure (no close frame received) + * - **1007**: Invalid frame payload data + * - **1008**: Policy violation + * - **1009**: Message too big + * - **1010**: Mandatory extension missing + * - **1011**: Internal server error + * + * ## Building + * + * ### Build the library: + * ```bash + * bazel build //seobeo:seobeo_client + * ``` + * + * ### Build and run the WebSocket test: + * ```bash + * bazel test //seobeo:seobeo_websocket_test + * ``` + * + * ## Message Structure + * + * ```c + * typedef struct { + * Seobeo_WebSocket_Opcode opcode; // Message type + * uint8 *data; // Message payload + * size_t length; // Payload length + * boolean is_final; // Final fragment flag + * } Seobeo_WebSocket_Message; + * ``` + * + * ## Protocol Details + * + * The implementation follows RFC 6455 + * + * 1. **Handshake**: HTTP Upgrade request with `Sec-WebSocket-Key` + * 2. **Frame Format**: Proper FIN, opcode, mask, and payload length handling + * 3. **Masking**: All client-to-server frames are masked (required by RFC) + * 4. **Fragmentation**: Handles fragmented messages across multiple frames + * 5. **Control Frames**: Proper handling of ping, pong, and close frames + */ + +/* Connect to WebSocket server with given URL (ws:// or wss://). */ +extern Seobeo_WebSocket *Seobeo_WebSocket_Connect(const char *url); +/* Send text message over WebSocket. */ +extern int32 Seobeo_WebSocket_Send_Text(Seobeo_WebSocket *p_ws, const char *text); +/* Send binary message over WebSocket. */ +extern int32 Seobeo_WebSocket_Send_Binary(Seobeo_WebSocket *p_ws, const uint8 *data, size_t length); +/* Send ping frame (for keep-alive). */ +extern int32 Seobeo_WebSocket_Send_Ping(Seobeo_WebSocket *p_ws, const char *payload); +/* Send pong frame (usually in response to ping). */ +extern int32 Seobeo_WebSocket_Send_Pong(Seobeo_WebSocket *p_ws, const char *payload); +/* Receive a message from WebSocket. Returns NULL if no message available or on error. */ +extern Seobeo_WebSocket_Message *Seobeo_WebSocket_Receive(Seobeo_WebSocket *p_ws); +/* Destroy received message. */ +extern void Seobeo_WebSocket_Message_Destroy(Seobeo_WebSocket_Message *p_msg); +/* Close WebSocket connection with status code and optional reason. */ +extern int32 Seobeo_WebSocket_Close(Seobeo_WebSocket *p_ws, uint16 code, const char *reason); +/* Destroy WebSocket and free all resources. */ +extern void Seobeo_WebSocket_Destroy(Seobeo_WebSocket *p_ws); + /* Initialize the router system (called automatically by Seobeo_Web_Server_Start) */ extern void Seobeo_Router_Init(); /* Register an API route handler. Call before starting server. */ diff -r c39582f937e5 -r cbbf78b17cfa seobeo/seobeo_internal.h --- a/seobeo/seobeo_internal.h Wed Jan 07 16:05:57 2026 -0800 +++ b/seobeo/seobeo_internal.h Thu Jan 08 03:19:59 2026 -0800 @@ -141,4 +141,57 @@ extern void Seobeo_Client_Request_Destroy(Seobeo_Client_Request *p_req); extern void Seobeo_Client_Response_Destroy(Seobeo_Client_Response *p_resp); +// --- WebSocket Types --- // +typedef enum { + SEOBEO_WS_OPCODE_CONTINUATION = 0x0, + SEOBEO_WS_OPCODE_TEXT = 0x1, + SEOBEO_WS_OPCODE_BINARY = 0x2, + SEOBEO_WS_OPCODE_CLOSE = 0x8, + SEOBEO_WS_OPCODE_PING = 0x9, + SEOBEO_WS_OPCODE_PONG = 0xA +} Seobeo_WebSocket_Opcode; + +typedef enum { + SEOBEO_WS_STATE_CONNECTING = 0, + SEOBEO_WS_STATE_OPEN, + SEOBEO_WS_STATE_CLOSING, + SEOBEO_WS_STATE_CLOSED +} Seobeo_WebSocket_State; + +typedef struct { + Seobeo_WebSocket_Opcode opcode; + uint8 *data; + size_t length; + boolean is_final; +} Seobeo_WebSocket_Message; + +typedef struct { + Seobeo_Handle *p_handle; + char *url; + char *host; + char *port; + char *path; + boolean use_tls; + + Seobeo_WebSocket_State state; + + uint8 *fragment_buffer; + size_t fragment_length; + size_t fragment_capacity; + Seobeo_WebSocket_Opcode fragment_opcode; + + Dowa_Arena *p_arena; +} Seobeo_WebSocket; + +// --- WebSocket Functions --- // +extern Seobeo_WebSocket *Seobeo_WebSocket_Connect(const char *url); +extern int32 Seobeo_WebSocket_Send_Text(Seobeo_WebSocket *p_ws, const char *text); +extern int32 Seobeo_WebSocket_Send_Binary(Seobeo_WebSocket *p_ws, const uint8 *data, size_t length); +extern int32 Seobeo_WebSocket_Send_Ping(Seobeo_WebSocket *p_ws, const char *payload); +extern int32 Seobeo_WebSocket_Send_Pong(Seobeo_WebSocket *p_ws, const char *payload); +extern Seobeo_WebSocket_Message *Seobeo_WebSocket_Receive(Seobeo_WebSocket *p_ws); +extern void Seobeo_WebSocket_Message_Destroy(Seobeo_WebSocket_Message *p_msg); +extern int32 Seobeo_WebSocket_Close(Seobeo_WebSocket *p_ws, uint16 code, const char *reason); +extern void Seobeo_WebSocket_Destroy(Seobeo_WebSocket *p_ws); + #endif diff -r c39582f937e5 -r cbbf78b17cfa seobeo/tests/seobeo_websocket_test.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_websocket_test.c Thu Jan 08 03:19:59 2026 -0800 @@ -0,0 +1,196 @@ +#include "seobeo/seobeo.h" +#include +#include +#include + +void Test_WebSocket_Echo() +{ + printf("\n=== Test: WebSocket Echo Server ===\n"); + + Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://echo.websocket.org"); + if (!p_ws) + { + printf("Failed to connect to WebSocket server\n"); + return; + } + + printf("Connected to WebSocket server\n"); + + const char *test_message = "Hello, WebSocket!"; + printf("Sending: %s\n", test_message); + + if (Seobeo_WebSocket_Send_Text(p_ws, test_message) < 0) + { + printf("Failed to send message\n"); + Seobeo_WebSocket_Destroy(p_ws); + return; + } + + printf("Waiting for echo response...\n"); + + int attempts = 0; + while (attempts < 100) + { + Seobeo_WebSocket_Message *p_msg = Seobeo_WebSocket_Receive(p_ws); + if (p_msg) + { + if (p_msg->opcode == SEOBEO_WS_OPCODE_TEXT) + { + printf("Received text: %.*s\n", (int)p_msg->length, (char*)p_msg->data); + } + else if (p_msg->opcode == SEOBEO_WS_OPCODE_BINARY) + { + printf("Received binary data (%zu bytes)\n", p_msg->length); + } + + Seobeo_WebSocket_Message_Destroy(p_msg); + break; + } + + usleep(10000); + attempts++; + } + + if (attempts >= 100) + printf("Timeout waiting for response\n"); + + Seobeo_WebSocket_Close(p_ws, 1000, "Test complete"); + Seobeo_WebSocket_Destroy(p_ws); + + printf("WebSocket test completed\n"); +} + +void Test_WebSocket_Multiple_Messages() +{ + printf("\n=== Test: Multiple Messages ===\n"); + + Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://echo.websocket.org"); + if (!p_ws) + { + printf("Failed to connect\n"); + return; + } + + const char *messages[] = { + "Message 1", + "Message 2", + "Message 3" + }; + + for (int i = 0; i < 3; i++) + { + printf("Sending: %s\n", messages[i]); + Seobeo_WebSocket_Send_Text(p_ws, messages[i]); + usleep(100000); + } + + printf("Receiving responses...\n"); + int received = 0; + int attempts = 0; + + while (received < 3 && attempts < 200) + { + Seobeo_WebSocket_Message *p_msg = Seobeo_WebSocket_Receive(p_ws); + if (p_msg) + { + if (p_msg->opcode == SEOBEO_WS_OPCODE_TEXT) + { + printf("Response %d: %.*s\n", received + 1, (int)p_msg->length, (char*)p_msg->data); + received++; + } + Seobeo_WebSocket_Message_Destroy(p_msg); + } + + usleep(10000); + attempts++; + } + + printf("Received %d/%d messages\n", received, 3); + + Seobeo_WebSocket_Destroy(p_ws); +} + +void Test_WebSocket_Binary_Data() +{ + printf("\n=== Test: Binary Data ===\n"); + + Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://echo.websocket.org"); + if (!p_ws) + { + printf("Failed to connect\n"); + return; + } + + uint8 binary_data[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + printf("Sending %zu bytes of binary data\n", sizeof(binary_data)); + + if (Seobeo_WebSocket_Send_Binary(p_ws, binary_data, sizeof(binary_data)) < 0) + { + printf("Failed to send binary data\n"); + Seobeo_WebSocket_Destroy(p_ws); + return; + } + + int attempts = 0; + while (attempts < 100) + { + Seobeo_WebSocket_Message *p_msg = Seobeo_WebSocket_Receive(p_ws); + if (p_msg) + { + if (p_msg->opcode == SEOBEO_WS_OPCODE_BINARY) + { + printf("Received %zu bytes of binary data: ", p_msg->length); + for (size_t i = 0; i < p_msg->length && i < 16; i++) + printf("%02X ", p_msg->data[i]); + printf("\n"); + } + + Seobeo_WebSocket_Message_Destroy(p_msg); + break; + } + + usleep(10000); + attempts++; + } + + Seobeo_WebSocket_Destroy(p_ws); +} + +void Test_WebSocket_Ping_Pong() +{ + printf("\n=== Test: Ping/Pong ===\n"); + + Seobeo_WebSocket *p_ws = Seobeo_WebSocket_Connect("wss://echo.websocket.org"); + if (!p_ws) + { + printf("Failed to connect\n"); + return; + } + + printf("Sending ping...\n"); + if (Seobeo_WebSocket_Send_Ping(p_ws, "ping payload") < 0) + { + printf("Failed to send ping\n"); + Seobeo_WebSocket_Destroy(p_ws); + return; + } + + printf("Ping sent (pong should be handled automatically)\n"); + + usleep(500000); + + Seobeo_WebSocket_Destroy(p_ws); +} + +int main() +{ + printf("=== Seobeo WebSocket Tests ===\n"); + + Test_WebSocket_Echo(); + Test_WebSocket_Multiple_Messages(); + Test_WebSocket_Binary_Data(); + Test_WebSocket_Ping_Pong(); + + printf("\n=== All Tests Complete ===\n"); + return 0; +}