WebSocket server plugin for ecewo, implementing RFC 6455 on top of ecewo's connection-takeover API.
#include "ecewo-ws.h"
static void on_message(ecewo_ws_t *ws,
const void *data, size_t len,
bool is_binary, void *user_data) {
ecewo_ws_send_text(ws, (const char *)data); // echo
}
static void chat(ecewo_request_t *req, ecewo_response_t *res) {
ecewo_ws_config_t *cfg = ecewo_ws_config_new();
ecewo_ws_config_set_on_message(cfg, on_message);
ecewo_ws_upgrade(req, res, cfg);
ecewo_ws_config_free(cfg);
}- Features
- Status and scope
- Requirements
- Installation
- Quick start
- Concepts
- API reference
- Protocol behavior
- Deployment
- FFI bindings
- Building from source
- Testing
- Troubleshooting
- License
- Server-side RFC 6455 WebSocket: text and binary frames, fragmentation, ping/pong, close handshake
- Opaque, FFI-friendly C API - no struct-literal access; configuration through builder + setters
- UTF-8 validation on text frames (RFC 3629), opt-out per upgrade
- Size limits for individual frames and assembled messages, with proper
1009close on overflow - Per-app state: every
ecewo_app_thas its own connection list, broadcast helpers, and counter - Zero TLS/crypto dependencies: SHA-1 + Base64 are vendored (the only crypto WebSocket itself needs)
- Clean teardown through ecewo's takeover lifecycle - connections are accounted for during shutdown
In scope:
- Server-side
ws:// - All required-by-spec frame handling, plus client→server unmasking with protocol-error rejection of unmasked frames
- Per-message UTF-8 validation
- Graceful close handshake
Not in scope (yet):
- TLS /
wss://- terminate at a reverse proxy, or wait forecewo-tls - Per-message-deflate (RFC 7692)
- Subprotocol negotiation (
Sec-WebSocket-Protocol) - header isn't parsed; you can read it manually fromreqif needed - Extension negotiation (
Sec-WebSocket-Extensions) - ignored - WebSocket client mode
- ecewo
v4(fetched automatically viaFetchContent) - CMake 3.14+
- A C11 compiler (GCC or Clang)
- libuv (pulled in transitively by ecewo)
Add to your CMakeLists.txt:
ecewo_add(ws@v0.1.0)
target_link_libraries(your_app PRIVATE ecewo::ecewo ecewo::ws)ecewo-ws declares ecewo itself, so you do not need a separate FetchContent_Declare(ecewo …). If your project has already declared ecewo (i.e. the ecewo::ecewo target already exists), the plugin reuses it.
To build as a shared library instead of static:
set(ECEWO_WS_BUILD_SHARED ON)#include "ecewo.h"
#include "ecewo-ws.h"
#include <stdio.h>
#include <string.h>
static void on_open(ecewo_ws_t *ws, void *user_data) {
(void)user_data;
ecewo_ws_send_text(ws, "welcome");
}
static void on_message(ecewo_ws_t *ws,
const void *data, size_t len,
bool is_binary, void *user_data) {
(void)user_data;
if (is_binary) {
ecewo_ws_send_binary(ws, data, len);
} else {
// Text payloads are NUL-terminated for convenience.
printf("got: %.*s\n", (int)len, (const char *)data);
ecewo_ws_send_text(ws, (const char *)data);
}
}
static void on_close(ecewo_ws_t *ws,
uint16_t code, const char *reason, size_t reason_len,
void *user_data) {
(void)ws; (void)user_data;
printf("closed code=%u reason=%.*s\n", code, (int)reason_len, reason);
}
static void chat(ecewo_request_t *req, ecewo_response_t *res) {
ecewo_ws_config_t *cfg = ecewo_ws_config_new();
ecewo_ws_config_set_on_open(cfg, on_open);
ecewo_ws_config_set_on_message(cfg, on_message);
ecewo_ws_config_set_on_close(cfg, on_close);
ecewo_ws_config_set_max_message_size(cfg, 64 * 1024); // 64 KiB cap
if (!ecewo_ws_upgrade(req, res, cfg)) {
// Upgrade failed; ecewo_ws_upgrade already sent a 4xx response.
// No further action needed.
}
ecewo_ws_config_free(cfg);
}
int main(void) {
ecewo_app_t *app = ecewo_create();
ECEWO_GET(app, "/chat", chat);
ecewo_listen(app, 8080);
return 0;
}Browser test:
const ws = new WebSocket("ws://localhost:8080/chat");
ws.onopen = () => ws.send("hello");
ws.onmessage = (e) => console.log("got:", e.data);When you call ecewo_ws_upgrade(req, res, cfg), the plugin validates the request in this order:
- Method must be
GET- otherwise sends405 Method Not Allowed. Upgrade: websocketmust be present - otherwise400 Bad Request.Sec-WebSocket-Version: 13must be present - otherwise426 Upgrade Requiredwith aSec-WebSocket-Version: 13hint header.Sec-WebSocket-Keymust be a valid base64-encoded 16-byte value - otherwise400 Bad Request.Connectionmust contain theUpgradetoken (case-insensitive, comma-separated lists OK) - otherwise400 Bad Request.
On success the plugin computes Sec-WebSocket-Accept, sends the 101 Switching Protocols response, takes over the underlying TCP socket, transitions the connection to OPEN, and fires on_open.
The order matters for diagnostics: a client sending the wrong protocol version gets a useful 426 with a hint header rather than a generic 400.
┌──────────────┐
│ CONNECTING │ set briefly inside ecewo_ws_upgrade
└──────┬───────┘
│ handshake OK
▼
┌──────────────┐
│ OPEN │ on_open fired; sends and receives
└──┬────────┬──┘
ecewo_ws_close()/ │ │ CLOSE frame received from peer
peer error │ │
▼ ▼
┌──────────────┐
│ CLOSING │
└──────┬───────┘
│ both directions of CLOSE done,
│ or socket error
▼
┌──────────────┐
│ CLOSED │ on_close fired (if registered),
└──────────────┘ ecewo_ws_t freed
The ecewo_ws_t handle is owned by the plugin from the moment ecewo_ws_upgrade() returns success until just after on_close returns. Holding a pointer past that is a use-after-free.
ecewo runs on a single libuv event loop. All callbacks fire on that loop thread. The plugin's own bookkeeping (ws_list_add, ws_list_remove, the per-app connection list) is therefore single-threaded - but so is the rest of your handler code.
Don't block in callbacks. If you need to do work that blocks, schedule it via ecewo_spawn / ecewo_spawn_http (or libuv's thread pool directly) and post the result back to the loop before touching ecewo_ws_t.
| Object | Lifetime | Who frees it |
|---|---|---|
ecewo_ws_t *ws |
From successful ecewo_ws_upgrade until just after on_close returns |
Plugin (do not free) |
ecewo_ws_config_t *cfg |
Until you free it | You - call ecewo_ws_config_free |
user_data (config or per-connection) |
Whatever you pass | You - typically free in on_close |
data in on_message |
Until the callback returns | Plugin (do not free, do not retain after return) |
text argument to ecewo_ws_send_text |
Used only during the call | You |
The config is copied during ecewo_ws_upgrade, so you can free it (or reuse it for another upgrade) immediately afterward.
This section is a tour with the important behaviors called out. The header file src/ecewo-ws.h is the canonical reference for signatures.
typedef struct ecewo_ws_s ecewo_ws_t; // opaque
typedef struct ecewo_ws_config_s ecewo_ws_config_t; // opaque
typedef enum {
ECEWO_WS_CONNECTING = 0,
ECEWO_WS_OPEN = 1,
ECEWO_WS_CLOSING = 2,
ECEWO_WS_CLOSED = 3
} ecewo_ws_state_t;
typedef enum {
ECEWO_WS_CLOSE_NORMAL = 1000,
ECEWO_WS_CLOSE_GOING_AWAY = 1001,
ECEWO_WS_CLOSE_PROTOCOL_ERROR = 1002,
ECEWO_WS_CLOSE_UNSUPPORTED_DATA = 1003,
ECEWO_WS_CLOSE_NO_STATUS = 1005, // peer sent empty close, do not send
ECEWO_WS_CLOSE_ABNORMAL = 1006, // socket died, do not send
ECEWO_WS_CLOSE_INVALID_PAYLOAD = 1007,
ECEWO_WS_CLOSE_POLICY_VIOLATION = 1008,
ECEWO_WS_CLOSE_MESSAGE_TOO_BIG = 1009,
ECEWO_WS_CLOSE_MANDATORY_EXTENSION = 1010,
ECEWO_WS_CLOSE_INTERNAL_ERROR = 1011
} ecewo_ws_close_code_t;Create with ecewo_ws_config_new(), free with ecewo_ws_config_free(). Returns NULL on allocation failure. Defaults are populated; setters override only the fields you touch.
| Setter | Default | Notes |
|---|---|---|
set_on_open(cb) |
none | Fires after handshake; connection is OPEN |
set_on_message(cb) |
none | Reassembled message; payload pointer is invalid after return |
set_on_close(cb) |
none | Fires once per connection just before teardown |
set_on_error(cb) |
none | Unrecoverable error (parse failure, write failure, etc.) |
set_on_ping(cb) |
none | Auto-pong is sent regardless of whether cb is registered |
set_on_pong(cb) |
none | Informational only |
set_max_message_size(n) |
1 MiB |
Larger assembled message → close with 1009 |
set_max_frame_size(n) |
16 MiB |
Larger single frame → close with 1009 |
set_validate_utf8(b) |
true |
Invalid UTF-8 in text frame → close with 1007 |
set_user_data(p) |
NULL |
Default user_data for every callback |
A value of 0 for the size setters means "use the default."
typedef void (*ecewo_ws_open_cb_t)(ecewo_ws_t *ws, void *user_data);
typedef void (*ecewo_ws_message_cb_t)(ecewo_ws_t *ws,
const void *data, size_t len,
bool is_binary, void *user_data);
typedef void (*ecewo_ws_close_cb_t)(ecewo_ws_t *ws,
uint16_t code,
const char *reason, size_t reason_len,
void *user_data);
typedef void (*ecewo_ws_error_cb_t)(ecewo_ws_t *ws,
const char *message,
void *user_data);
typedef void (*ecewo_ws_ping_cb_t)(ecewo_ws_t *ws,
const void *data, size_t len,
void *user_data);
typedef void (*ecewo_ws_pong_cb_t)(ecewo_ws_t *ws,
const void *data, size_t len,
void *user_data);For on_message, text payloads are NUL-terminated for convenience but len is authoritative - text bodies may legally contain embedded NULs (RFC 6455 §5.6).
ecewo_ws_t *ecewo_ws_upgrade(ecewo_request_t *req,
ecewo_response_t *res,
const ecewo_ws_config_t *config);- On success: returns a non-NULL handle. The 101 response has been sent, ecewo has handed off the TCP socket, and
on_openhas fired. - On failure: returns
NULLafter sending an appropriate 4xx/5xx response (405,400,426, or500). Do not call any further response functions in your handler. configmay beNULLto use all defaults.
int ecewo_ws_send_text(ecewo_ws_t *ws, const char *text);
int ecewo_ws_send_binary(ecewo_ws_t *ws, const void *data, size_t len);
int ecewo_ws_ping(ecewo_ws_t *ws, const void *data, size_t len); // len ≤ 125
int ecewo_ws_pong(ecewo_ws_t *ws, const void *data, size_t len); // len ≤ 125
int ecewo_ws_close(ecewo_ws_t *ws, uint16_t code, const char *reason);All return 0 on success and -1 on error. Errors include:
ws == NULLwsis not inOPENstate (sends and pings are rejected)- For
send_textwithvalidate_utf8on: payload is not valid UTF-8 - For
ping/pong:len > 125(RFC 6455 §5.5) - libuv write submission failed
ecewo_ws_close is idempotent: calling it after a CLOSE has already been sent is a no-op that returns 0. The reason string is truncated to 123 bytes (the close-frame payload is code (2) + reason (≤123) = 125).
ecewo_ws_close does not free the connection synchronously. It sends the CLOSE frame, transitions to CLOSING, and waits for the peer's CLOSE before tearing down. If the peer's CLOSE has already arrived, teardown happens immediately.
ecewo_ws_state_t ecewo_ws_state(const ecewo_ws_t *ws);
bool ecewo_ws_is_open(const ecewo_ws_t *ws);
void *ecewo_ws_user_data(const ecewo_ws_t *ws);
void ecewo_ws_set_user_data(ecewo_ws_t *ws, void *user_data);
ecewo_app_t *ecewo_ws_app(const ecewo_ws_t *ws);ecewo_ws_state(NULL) returns ECEWO_WS_CLOSED rather than crashing.
size_t ecewo_ws_broadcast_text (ecewo_app_t *app, const char *text);
size_t ecewo_ws_broadcast_binary(ecewo_app_t *app, const void *data, size_t len);
size_t ecewo_ws_connection_count(ecewo_app_t *app);broadcast_* enqueue the message to every OPEN connection on app and return the number of successful sends. Connections in CONNECTING or CLOSING are skipped. The count returned by connection_count includes all tracked states (CONNECTING, OPEN, CLOSING) - it tracks "connections the plugin owns," not "connections you can send to."
There is one connection list per ecewo_app_t. If you run two apps in the same process, broadcasting to one does not reach the other.
int ecewo_ws_compute_accept(const char *ws_key, char *out, size_t out_size);Computes base64(sha1(ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")), the value of the Sec-WebSocket-Accept response header (RFC 6455 §4.2.2). out_size must be at least 32 (28 base64 chars + NUL). Mostly useful for tests and FFI bindings; the plugin uses it internally during the handshake.
When validate_utf8 is on (the default):
- Outbound text via
ecewo_ws_send_text: rejected with-1if invalid; no frame is sent. - Inbound text frames: an invalid sequence triggers a
1007close, theon_closecallback fires, and the connection is torn down.
Validation is full RFC 3629 - overlong encodings, surrogate halves, and code points above U+10FFFF are rejected. You can disable it with set_validate_utf8(cfg, false) if you have a reason (you almost certainly don't; non-validating servers tend to crash JavaScript clients).
Inbound frames are reassembled into a single message buffer before on_message fires. A message is "complete" when a frame with FIN=1 arrives and the running opcode chain is consistent (initial frame TEXT or BINARY, all continuations CONTINUATION).
Control frames (PING, PONG, CLOSE) may be interleaved with data frame fragments, per RFC 6455. The plugin handles them out-of-band without disturbing in-progress reassembly.
Two independent limits apply per connection:
max_frame_size- applied to the payload length of any single frame. Exceeding it triggers a1009close before the frame is read into memory.max_message_size- applied to the running total of an in-progress reassembly. Exceeding it triggers1009.
Defaults are 1 MiB per assembled message, 16 MiB per frame. Tune them per upgrade based on what your app actually accepts. Smaller is generally safer.
Inbound PING: the plugin automatically replies with a PONG carrying the same payload, then fires on_ping (if registered) for visibility.
Inbound PONG: fires on_pong (if registered). The plugin does not currently track outstanding pings or enforce a ping-timeout (the *_PING_INTERVAL_MS / *_PING_TIMEOUT_MS defaults in the header are reserved for a future heartbeat feature).
Outbound ecewo_ws_ping(ws, data, len): payload must be ≤ 125 bytes (RFC 6455 §5.5). data may be NULL if len == 0.
ecewo_ws_close(ws, ECEWO_WS_CLOSE_NORMAL, "bye");This sends a CLOSE frame and transitions to CLOSING. The plugin then waits for the peer's CLOSE and tears the connection down on either:
- The peer's CLOSE arriving (normal path), or
- The TCP read returning EOF or an error (abnormal path → close code becomes
1006).
on_close fires once per connection, with whichever code/reason was observed:
- If the peer initiated, the peer's code/reason.
- If you initiated and the peer's CLOSE arrived, the peer's echo.
- If the socket died first, code =
1006(ABNORMAL), reason = empty. - If the peer sent an empty CLOSE, code =
1005(NO_STATUS).
The plugin sends a CLOSE with one of these codes when the peer misbehaves:
| Code | Trigger |
|---|---|
1002 PROTOCOL_ERROR |
unmasked client→server frame; reserved bits set; bad opcode; fragmented control frame; control frame > 125 bytes |
1007 INVALID_PAYLOAD |
invalid UTF-8 in a text frame |
1009 MESSAGE_TOO_BIG |
frame or message exceeds configured limit |
Per RFC 6455, all client→server frames must be masked. The plugin rejects unmasked frames with a 1002 close - this is a common bug in hand-rolled clients.
This plugin handles only the WebSocket protocol; it does not do TLS. For wss:// deployments you have three options:
- Reverse proxy (recommended today). nginx, Caddy, HAProxy, or a managed edge (Cloudflare, Fly, Fastly) terminate TLS and forward
ws://to your ecewo process. ecewo-tlsplugin (planned). Will let you compose TLS directly under WebSocket without an external proxy.- TLS termination in front of your binary by some other means (e.g., a sidecar).
For (1), the plugin works unchanged - TLS is invisible to it.
The proxy must forward the upgrade headers verbatim. nginx requires explicit configuration:
location /chat {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_read_timeout 1h; # WebSocket connections are long-lived
}Caddy does this automatically; no special config is needed for reverse_proxy.
If your proxy strips or rewrites Connection, the handshake will fail with the plugin's 400 Bad Request ("websocket: bad Connection header"). Check that Connection: Upgrade actually arrives at ecewo using a tcpdump or a debug log.
The API is designed to be wrappable from any language with C FFI:
- No struct literals. Every type that crosses the API boundary is opaque; access is through getter/setter calls.
- No callback-passing function pointers from the language side that need C-friendly trampolines beyond what FFI already supports - the callback signatures use plain function pointers with
void *user_data. - No globals. Per-connection state is in
ecewo_ws_t; per-app state is inecewo_app_t's app-data slot (keyed by an internal address, so it doesn't collide with anything else). - Stable allocation contract. The plugin never frees pointers you give it (your
user_data, yourtextargument, etc.); you never free pointers it gives you (thedatainon_message, theecewo_ws_t *).
A typical binding shape (Python-style pseudocode):
ws_t = ctypes.c_void_p
cfg = lib.ecewo_ws_config_new()
lib.ecewo_ws_config_set_on_message(cfg, on_message_callback)
ws = lib.ecewo_ws_upgrade(req, res, cfg)
lib.ecewo_ws_config_free(cfg)cmake -B build -S .
cmake --build buildCMake options:
| Option | Default | Effect |
|---|---|---|
ECEWO_WS_BUILD_SHARED |
OFF |
Build libecewo-ws as a shared library |
ECEWO_WS_BUILD_TESTS |
OFF |
Build the test binary (ecewo-ws-test) and register it with CTest |
cmake -B build -S . -DECEWO_WS_BUILD_TESTS=ON
cmake --build build
ctest --test-dir build --output-on-failureThe suite exercises:
- Handshake validation matrix -
405for non-GET,400for missingUpgrade,426(with hint header) for wrong version,400for badSec-WebSocket-Key,400for missingConnection: Upgradetoken (including comma-separated lists) Sec-WebSocket-Acceptreference vector from RFC 6455 §1.3- Config builder semantics (defaults, setter coverage, freeing)
- Round-trip framing - text echo, binary echo, fragmentation
- Ping → pong auto-reply
- Close handshake - both server- and client-initiated
- Per-app bookkeeping -
connection_countincrements and decrements, broadcast reaches the right app - Protocol errors -
1002for unmasked client frames
The test binary uses the ecewo::mock plugin to bring up a real ecewo server on localhost:8888 and connects with a small libuv-based WebSocket client, so the suite exercises the actual takeover path.
400 Bad Request from /<your-route> - "websocket: bad Connection header"
The client (or a proxy in front of you) didn't send Connection: Upgrade. Inspect the actual headers on the wire. nginx in particular needs explicit proxy_set_header Connection "Upgrade";.
426 Upgrade Required
Client sent Sec-WebSocket-Version other than 13. Anything written in the last decade should send 13; this almost always means the request isn't actually a WebSocket upgrade.
Connection closes immediately with code 1002
Client is sending unmasked frames. Browsers and any compliant client mask correctly; this usually means a hand-rolled client built without reading RFC 6455 §5.3.
Connection closes with code 1007
A text frame contained invalid UTF-8. Either fix the client or call ecewo_ws_config_set_validate_utf8(cfg, false) (not recommended).
Connection closes with code 1009
Frame or message exceeded your configured limit. Raise the limit, fragment on the client, or use binary.
on_close is not firing
Check that you actually registered it - ecewo_ws_config_set_on_close(cfg, …) must run before ecewo_ws_upgrade. The config is copied at upgrade time; later changes to cfg are not reflected.
Use-after-free crash in on_close or after the connection ends
You're holding a ecewo_ws_t * past the end of on_close. The handle is freed immediately after on_close returns. Copy any fields you need before returning.
MIT. See LICENSE and individual source-file headers.