Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ecewo-ws

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);
}

Table of contents


Features

  • 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 1009 close on overflow
  • Per-app state: every ecewo_app_t has 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

Status and scope

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 for ecewo-tls
  • Per-message-deflate (RFC 7692)
  • Subprotocol negotiation (Sec-WebSocket-Protocol) - header isn't parsed; you can read it manually from req if needed
  • Extension negotiation (Sec-WebSocket-Extensions) - ignored
  • WebSocket client mode

Requirements

  • ecewo v4 (fetched automatically via FetchContent)
  • CMake 3.14+
  • A C11 compiler (GCC or Clang)
  • libuv (pulled in transitively by ecewo)

Installation

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)

Quick start

#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);

Concepts

The upgrade handshake

When you call ecewo_ws_upgrade(req, res, cfg), the plugin validates the request in this order:

  1. Method must be GET - otherwise sends 405 Method Not Allowed.
  2. Upgrade: websocket must be present - otherwise 400 Bad Request.
  3. Sec-WebSocket-Version: 13 must be present - otherwise 426 Upgrade Required with a Sec-WebSocket-Version: 13 hint header.
  4. Sec-WebSocket-Key must be a valid base64-encoded 16-byte value - otherwise 400 Bad Request.
  5. Connection must contain the Upgrade token (case-insensitive, comma-separated lists OK) - otherwise 400 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.

Connection lifecycle

                        ┌──────────────┐
                        │  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.

Threading model

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.

Memory ownership

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.


API reference

This section is a tour with the important behaviors called out. The header file src/ecewo-ws.h is the canonical reference for signatures.

Types and enums

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;

Configuration

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."

Callback signatures

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).

Upgrade

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_open has fired.
  • On failure: returns NULL after sending an appropriate 4xx/5xx response (405, 400, 426, or 500). Do not call any further response functions in your handler.
  • config may be NULL to use all defaults.

Sending

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 == NULL
  • ws is not in OPEN state (sends and pings are rejected)
  • For send_text with validate_utf8 on: 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.

State

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.

Per-app broadcast

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.

Helper

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.


Protocol behavior

UTF-8 validation

When validate_utf8 is on (the default):

  • Outbound text via ecewo_ws_send_text: rejected with -1 if invalid; no frame is sent.
  • Inbound text frames: an invalid sequence triggers a 1007 close, the on_close callback 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).

Message reassembly

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.

Size limits

Two independent limits apply per connection:

  • max_frame_size - applied to the payload length of any single frame. Exceeding it triggers a 1009 close before the frame is read into memory.
  • max_message_size - applied to the running total of an in-progress reassembly. Exceeding it triggers 1009.

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.

Ping / pong

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.

Close handshake

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).

Protocol-error close codes

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.


Deployment

TLS (wss://)

This plugin handles only the WebSocket protocol; it does not do TLS. For wss:// deployments you have three options:

  1. Reverse proxy (recommended today). nginx, Caddy, HAProxy, or a managed edge (Cloudflare, Fly, Fastly) terminate TLS and forward ws:// to your ecewo process.
  2. ecewo-tls plugin (planned). Will let you compose TLS directly under WebSocket without an external proxy.
  3. 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.

Reverse-proxy notes

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.


FFI bindings

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 in ecewo_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, your text argument, etc.); you never free pointers it gives you (the data in on_message, the ecewo_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)

Building from source

cmake -B build -S .
cmake --build build

CMake 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

Testing

cmake -B build -S . -DECEWO_WS_BUILD_TESTS=ON
cmake --build build
ctest --test-dir build --output-on-failure

The suite exercises:

  • Handshake validation matrix - 405 for non-GET, 400 for missing Upgrade, 426 (with hint header) for wrong version, 400 for bad Sec-WebSocket-Key, 400 for missing Connection: Upgrade token (including comma-separated lists)
  • Sec-WebSocket-Accept reference 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_count increments and decrements, broadcast reaches the right app
  • Protocol errors - 1002 for 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.

Troubleshooting

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.

License

MIT. See LICENSE and individual source-file headers.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages