Skip to content

WebSocket SSE

github-actions[bot] edited this page Oct 1, 2026 · 22 revisions

WebSocket and Server-Sent Events

Elio provides full support for WebSocket (RFC 6455) and Server-Sent Events (SSE) for real-time bidirectional and unidirectional communication.

Design Notes

The WebSocket implementation follows RFC 6455, including frame masking for all client-to-server messages as required by the specification. Masking prevents cache poisoning attacks on intermediary proxies that might misinterpret WebSocket frames as HTTP. The server-side does not mask frames, as the RFC only mandates client-to-server masking.

Server-Sent Events (SSE) provides a simpler alternative to WebSocket for server-to-client streaming. SSE operates over standard HTTP, requires no protocol upgrade, and benefits from automatic reconnection built into the browser EventSource API. The tradeoff is that SSE is unidirectional (server to client only), but for many use cases -- notifications, live feeds, progress updates -- this is sufficient and avoids the complexity of a bidirectional protocol.

WebSocket

HTTP CONNECT is a separate 2xx tunnel handoff, not a WebSocket 101 upgrade or an SSE body. A WebSocket server's ordinary HTTP fallback uses the shared reply dispatcher and can host router.connect(); CONNECT is not eligible for its WebSocket-upgrade path. After successful CONNECT acceptance, the scoped session owns opaque tunnel I/O until completion and HTTP parsing never resumes. See CONNECT handoff for authority routing, borrowed buffers, TLS closure and caller-owned proxy authorization policy.

WebSocket provides full-duplex communication channels over a single TCP connection.

WebSocket Server

#include <elio/elio.hpp>
#include <elio/http/websocket.hpp>

using namespace elio;
using namespace elio::http::websocket;

// WebSocket message handler
coro::task<void> echo_handler(ws_connection& conn) {
    while (conn.is_open()) {
        auto msg = co_await conn.receive();
        if (!msg) break;
        
        // Echo back the message
        if (msg->type == opcode::text) {
            co_await conn.send_text(msg->data);
        } else if (msg->type == opcode::binary) {
            co_await conn.send_binary(msg->data);
        }
    }
}

coro::task<int> async_main(int argc, char* argv[]) {
    (void)argc;
    (void)argv;

    // Create WebSocket-enabled router
    ws_router router;
    
    // Regular HTTP routes
    router.get("/", [](http::context& ctx) -> coro::task<http::response> {
        co_return http::response::ok("Hello!");
    });
    
    // WebSocket routes
    server_config ws_config;
    ws_config.max_message_size = 1024 * 1024;  // 1MB
    
    router.websocket("/ws", echo_handler, ws_config);
    
    // Create and start server
    ws_server srv(std::move(router));
    
    auto bind_addr = net::socket_address(net::ipv4_address(8080));

    // Run until SIGINT/SIGTERM, then stop the listener cleanly.
    co_await elio::serve(srv, [&]() { return srv.listen(bind_addr); });
    co_return 0;
}

int main(int argc, char* argv[]) {
    elio::signal::signal_set shutdown_signals(elio::default_shutdown_signals);
    shutdown_signals.block_all_threads();
    return elio::run(async_main, argc, argv);
}

WebSocket Route Patterns

ws_router::websocket() uses the same path pattern grammar as the HTTP router:

Pattern form Meaning
/ws Matches the literal path /ws
/chat/:room Captures one path component as room; read it with conn.param("room")
/assets/* Matches /assets/ and any remaining path tail

The * wildcard is only valid as the final path segment. Patterns such as /chat/*/admin are rejected during route registration because a non-terminal wildcard would make the remaining literal segments unreachable.

Named :param captures are stored on the ws_connection passed to the handler. Use conn.param("name") for one value or conn.params() to inspect all captured path parameters.

The HTTP upgrade request is governed by http::server_config::max_request_size. That limit counts the upgrade request line, headers, and body, but does not count bytes after the upgrade request has completed. Post-upgrade WebSocket frame and message bytes are governed by websocket::server_config limits such as max_message_size.

websocket::server_config::ping_interval enables an automatic server heartbeat for upgraded connections on that route. The server has up to ping_timeout to send each heartbeat ping and observe a pong processed by the handler's conn.receive() loop. If the ping cannot be sent or no pong is observed before that window expires, the connection fails closed. A heartbeat timeout may close the transport without delivering a WebSocket close frame. The heartbeat task does not read from the stream, so route handlers remain the single reader for the connection. Set ping_interval <= 0 to disable the heartbeat, or ping_timeout <= 0 to send periodic pings without timeout closure. After a WSS upgrade completes, the underlying TLS stream serializes direct OpenSSL state access so the receive loop can wait for inbound frames while the heartbeat writes pings.

WebSocket Client

#include <elio/elio.hpp>
#include <elio/http/websocket.hpp>

using namespace elio;
using namespace elio::http::websocket;

coro::task<void> connect_example() {
    // Create client
    client_config config;
    config.subprotocols = {"chat", "json"};  // Optional subprotocols
    config.connect_timeout = std::chrono::seconds(10);
    config.read_timeout = std::chrono::seconds(30);  // Upgrade response deadline

    ws_client client(config);
    coro::cancel_source cancel;
    
    // Connect to server
    if (!co_await client.connect("ws://localhost:8080/ws",
                                 cancel.get_token())) {
        ELIO_LOG_ERROR("Failed to connect");
        co_return;
    }
    
    ELIO_LOG_INFO("Connected! Subprotocol: {}", client.subprotocol());
    
    // Send messages
    co_await client.send_text("Hello, server!");
    co_await client.send_binary("\x01\x02\x03");
    
    // Receive messages
    while (client.is_open()) {
        auto msg = co_await client.receive(cancel.get_token());
        if (!msg) break;
        
        if (msg->type == opcode::text) {
            ELIO_LOG_INFO("Received: {}", msg->data);
        }
    }
    
    // Close connection
    co_await client.close(close_code::normal, "Done");
}

ws_client::connect() performs one connection attempt and does not install an automatic reconnect loop. If connect() fails, or a later operation observes a closed or failed connection, the caller decides whether to retry, how long to back off, whether application messages are safe to replay, and which connection state should be restored. A caller-managed reconnect can call connect() again once the client is closed.

WebSocket Frame Types

Opcode Name Description
0x0 continuation Continuation of fragmented message
0x1 text UTF-8 text data
0x2 binary Binary data
0x8 close Connection close
0x9 ping Ping (keep-alive)
0xA pong Pong (response to ping)

Close Codes

Code Name Description
1000 normal Normal closure
1001 going_away Endpoint going away
1002 protocol_error Protocol error
1003 unsupported Unsupported data type
1008 policy_violation Policy violation
1009 too_large Message too big
1011 unexpected Unexpected condition

Secure WebSocket (WSS)

For secure connections, use wss:// URLs and configure TLS:

// Server with TLS
auto tls_ctx = tls::tls_context::make_server("cert.pem", "key.pem");
auto task = srv.listen_tls(net::ipv4_address(8443), tls_ctx);

// Client with TLS
ws_client client;
client.tls_context().use_default_verify_paths();
co_await client.connect("wss://example.com/ws");

The server-side TLS handshake is bounded by the http::server_config passed to ws_server; keep_alive_timeout controls both the inbound TLS handshake and the HTTP upgrade request read. Set it to a non-positive duration to disable those deadlines.

Server-Sent Events (SSE)

SSE provides server-to-client event streaming over HTTP.

SSE Server

#include <elio/elio.hpp>
#include <elio/http/sse.hpp>

using namespace elio;
using namespace elio::http::sse;

// Event stream handler
coro::task<void> event_stream(sse_connection& conn) {
    int counter = 0;
    
    // Send retry interval
    co_await conn.send_retry(3000);  // 3 seconds
    
    while (conn.is_active()) {
        ++counter;
        
        // Send a simple event
        co_await conn.send_data("Counter: " + std::to_string(counter));
        
        // Send a typed event
        event evt = event::full(
            std::to_string(counter),  // id
            "update",                  // type
            R"({"count":)" + std::to_string(counter) + "}"
        );
        co_await conn.send(evt);
        
        // Wait before next event
        co_await time::sleep_for(std::chrono::seconds(1));
    }
}

SSE Client

#include <elio/elio.hpp>
#include <elio/http/sse.hpp>

using namespace elio;
using namespace elio::http::sse;

coro::task<void> listen_events() {
    // Configure client
    client_config config;
    config.auto_reconnect = true;
    config.default_retry_ms = 3000;
    config.connect_timeout = std::chrono::seconds(10);
    config.read_timeout = std::chrono::seconds(30);  // Response header deadline
    
    sse_client client(config);
    coro::cancel_source cancel;
    
    // Connect
    if (!co_await client.connect("http://localhost:8080/events",
                                 cancel.get_token())) {
        ELIO_LOG_ERROR("Failed to connect");
        co_return;
    }
    
    // Receive events
    while (client.is_connected()) {
        auto evt = co_await client.receive(cancel.get_token());
        if (!evt) break;
        
        ELIO_LOG_INFO("Event: type={} id={} data={}", 
                      evt->type.empty() ? "message" : evt->type,
                      evt->id,
                      evt->data);
    }
}

For WebSocket and SSE clients, connect_timeout bounds TCP connect and TLS handshake setup. read_timeout bounds the protocol response headers read by connect() -- the WebSocket 101 Switching Protocols response or the SSE text/event-stream response headers. Values less than or equal to zero disable these client-side read deadlines.

dns_timeout independently bounds DNS observation during each new connection attempt; zero/negative disables it and the default is zero. dns_domain selects shared bounded admission (null uses the capacity-64 default). The TCP/TLS budget still starts after DNS. Cancellation/expiry can leave owned running libc work behind until it returns, so normal shutdown may still drain it. See Networking for cache lifetime, overload, and absolute deadline semantics.

Both client configs inherit the shared HTTP base_client_config fields: read_buffer_size, user_agent, verify_certificate, resolve_options, rotate_resolved_addresses, max_headers, and max_header_size. These control buffer sizing, TLS verification, DNS cache/rotation behavior, and response header parser limits during connection setup.

Both clients also provide cancellation-token overloads. Cancelling the token passed to connect() leaves pending DNS observation or aborts pending TCP connect, TLS handshake, request write, and response header reads. Cancelling the token passed to receive() aborts a blocked WebSocket frame or SSE event read. Cancellation returns false or std::nullopt and sets errno to ECANCELED. For SSE, the per-receive token also covers automatic reconnect backoff and connection setup initiated by that receive call; the original connect token remains independently effective.

SSE Event Format

For managed responses, sse::event_view::id is an std::optional<std::string_view>. Omission (std::nullopt or {}) preserves the receiver's Last-Event-ID. An engaged empty view (std::string_view{} or "") emits id:\n and clears it; later events omitting id retain that cleared state. For example, writer.send_event({std::string_view{}, {}, "reset"}) sends an explicit reset with data. send_data() omits id. These fields remain borrowed through completion; presence adds no owned string or allocation. This does not change legacy raw sse_connection serialization.

SSE is the decoded HTTP body, not the raw bytes immediately after response headers. The client supports Content-Length, chunked transfer encoding, and close-delimited bodies. It removes chunk metadata before event parsing and stops at the declared body boundary. connect() returns after valid final headers; receive() processes any body bytes that arrived in the same read.

For example, this escaped HTTP/1.1 response contains one data: hi event; the chunk length a counts the ten payload bytes, not its framing:

HTTP/1.1 200 OK\r\n
Content-Type: text/event-stream\r\n
Transfer-Encoding: chunked\r\n
\r\n
a\r\n
data: hi\n\n
\r\n
0\r\n
\r\n

Equivalently, a finite stream can send Content-Length: 10 followed by data: hi\n\n, without the chunk-size or chunk-delimiter bytes. Never label raw SSE data as chunked without encoding real HTTP chunks, and never advertise a guessed Content-Length. Chunk boundaries can split event fields or UTF-8 bytes; they do not define event boundaries.

sse::client_config::max_informational_responses defaults to 16; zero disallows preceding interims. The existing absolute header deadline covers all interims. The client rejects 101 as an SSE response. With automatic reconnect disabled, normal body completion returns std::nullopt and errno = 0; truncated length or chunk framing returns EBADMSG. Previously delivered events remain delivered. With reconnect enabled, completion/failure follows the configured receive-driven reconnect policy and Last-Event-ID handling; this is not exactly-once delivery.

For outgoing SSE, return sse::make_streaming_response(producer) from an HTTP route. The owned producer receives sse::event_writer& and a cancellation token, and returns task<send_result>. Await and check every event write; successful producer return delegates final HTTP framing to the server. Unknown-length HTTP/1.1 streams use real chunks by default. The event writer borrows data and field slices through each await without concatenating a complete event body. It rejects CR/LF/NUL in id/type before emitting the event; data and comments support multiline input. Do not use writers concurrently or retain them after producer return.

build_sse_response() is removed. The new factory supplies Content-Type and Cache-Control but does not grant CORS permission or force Connection: close. Set cross-origin policy explicitly when needed. Legacy raw-stream sse_connection cannot be used to bypass the managed HTTP writer. See examples/sse_server.cpp, HTTP Streaming, and Migrating to 0.6 for complete examples, framing policy, borrowing, and cancellation boundaries.

SSE events are formatted as text with specific fields:

id: event-123
event: notification
retry: 3000
data: Hello, World!
data: This is line 2

  • id: - Event ID for reconnection tracking
  • event: - Event type (default: "message")
  • retry: - Reconnection interval in milliseconds
  • data: - Event data (can span multiple lines)
  • Empty line marks end of event

SSE vs WebSocket

Feature SSE WebSocket
Direction Server → Client only Bidirectional
Protocol HTTP Custom over TCP
Reconnection Automatic in browser EventSource; configurable in sse_client Caller-managed; no automatic ws_client loop
Browser support EventSource API WebSocket API
Complexity Simple More complex
Best for Notifications, feeds Chat, games, real-time

When to Use SSE

  • Real-time notifications
  • Live feeds (news, stocks)
  • Progress updates
  • Log streaming
  • Any server-push scenario without client messages

When to Use WebSocket

  • Chat applications
  • Real-time games
  • Collaborative editing
  • Any scenario requiring bidirectional communication

Browser Integration

WebSocket (JavaScript)

const ws = new WebSocket('ws://localhost:8080/ws');

ws.onopen = () => {
    console.log('Connected');
    ws.send('Hello, server!');
};

ws.onmessage = (event) => {
    console.log('Received:', event.data);
};

ws.onclose = (event) => {
    console.log('Disconnected:', event.code);
};

// Send messages
ws.send('Hello');
ws.send(new Blob([1, 2, 3]));

// Close
ws.close(1000, 'Done');

SSE (JavaScript)

const es = new EventSource('/events');

es.onmessage = (event) => {
    console.log('Message:', event.data);
};

es.addEventListener('notification', (event) => {
    console.log('Notification:', event.data);
    console.log('ID:', event.lastEventId);
});

es.onerror = (error) => {
    console.error('Error:', error);
    // EventSource will auto-reconnect
};

// Close
es.close();

Examples

Complete examples are available:

  • examples/websocket_server.cpp - WebSocket server with echo and chat
  • examples/websocket_client.cpp - WebSocket client demo
  • examples/sse_server.cpp - SSE event streaming server
  • examples/sse_client.cpp - SSE event receiver

Next Steps

Clone this wiki locally