Skip to content

websocket: add WebSocket client module - #428

Draft
hitech95 wants to merge 4 commits into
jow-:masterfrom
hitech95:websocket-module
Draft

websocket: add WebSocket client module#428
hitech95 wants to merge 4 commits into
jow-:masterfrom
hitech95:websocket-module

Conversation

@hitech95

@hitech95 hitech95 commented Sep 1, 2026

Copy link
Copy Markdown

Hi,

this adds a new websocket module so ucode scripts can talk to
WebSocket (RFC 6455) servers. Its completly event driven and hooked
into uloop, so it plays nice with the usual event loop based scripts:

import { connect } from 'websocket';
import * as uloop from 'uloop';

uloop.init();

let ws = connect('ws://10.0.0.1:8080/telemetry', {
    headers: { Authorization: 'Bearer ...' },
    max_frame_size: 65536
});

ws.on('open',    (ws) => ws.send('hello'));
ws.on('message', (ws, data, is_text) => print(data));
ws.on('close',   (ws, code, reason) => print(`closed ${code}\n`));
ws.on('error',   (ws, err) => print(`error: ${err}\n`));

uloop.run();

Some notes on the design descisions:

  • Framing is done by wslay (MIT), which is a small non-IO library -
    the caller drives the event loop, which fits our embeded use case nicely. It is linked the same way zlib is linked:
    WEBSOCKET_SUPPORT automaticaly defaults on when the libray is found, no vendored code.
  • Since ucode arrow functions have no this, the callbacks recieve the connection as explicit first argument.
  • Memory was a main concern: there is no per-message allocations in our layer, wslay reuses its fixed 4 KiB buffers and
    recieved message sizes are capped thru max_frame_size (default 256 KiB, oversize messages get closed with 1009).
  • Ping/pong and the close handshake (incl. close codes and reason) are handled automaticallly. Close during connect aborts the connection.
  • DNS resolving is still synchronus getaddrinfo for now (same as the socket module), everything after that is async.

Tests: there is a cram suite (test_websocket.t) with a small scripted fixture server that speaks the protocol independantly of wslay, covering handshake validation, echo, fragmentation, oversized
frames, resets, floods etc.
Everything was also run thru ASan and cross compiled + runtime tested on aarch64/musl (qemu).

On the OpenWrt side I'm working on a PR adding libwslay to package/libs, plus the ucode-mod-websocket package defininition happy to send those once/if this is acceptted here.

Known limitations: no TLS (wss:// fails with a clear error for now) and no permessage-deflate (on purpose, memory).
Server role is not planned unless someone actualy needs it.

Real world user case why this exist:
Iḿ making a status bridge between CamillaDSP websocket interface and ubus.

Feedback very welcome, especialy on the API surface and the error
handling conventions.

Add a new websocket module providing WebSocket (RFC 6455) client
connectivity to ucode scripts through an event driven, uloop based
API.

Follow the same integration pattern as the zlib module: the wslay
library is discovered with find_library()/find_path() and linked
externally, WEBSOCKET_SUPPORT automatically defaults on when the
library is present. A corresponding libwslay package is proposed
for the OpenWrt core tree.

This is the initial scaffold: the connect() entry point is
registered but raises a "not implemented" exception until the
connection state machine, handshake and wslay event wiring land.

Signed-off-by: Nicolò Veronese <nicveronese@gmail.com>
Implement the WebSocket (RFC 6455) client on top of the module
skeleton:

- URL parsing for ws:// (wss:// is rejected until TLS support lands),
  including IPv6 literals in bracket notation (stored unbracketed for
  getaddrinfo(), re-added for the Host header as required by RFC 7230),
  explicit ports, query strings; userinfo is rejected with a clear
  error
- Synchronous getaddrinfo resolution followed by a fully asynchronous,
  uloop driven non-blocking connect, handshake and data exchange
- RFC 6455 opening handshake: random nonce generation, locally
  implemented SHA-1 and base64, HTTP upgrade request assembly with
  optional custom headers (only the 'headers' option value is
  serialized; values containing line breaks are rejected) and
  Sec-WebSocket-Accept validation
- wslay event integration wired to the socket fd with automatic
  ping/pong handling, close handshake tracking and EAGAIN aware
  read/write event management
- Event callbacks (open, message, close, error) receive the connection
  resource as explicit first argument since ucode arrow functions have
  no lexical this binding
- Frame reception bounded through wslay max_recv_msg_length, exposed
  as max_frame_size option (default 256 KiB, range 1 KiB to 16 MiB)
- Bytes arriving pipelined behind the handshake response are handed
  to the wslay receive path instead of being discarded
- The connection struct is explicitly zeroed since ucode resource
  data regions are not zero-initialized; all exception raising
  argument validation branches are properly guarded with returns

Signed-off-by: Nicolò Veronese <nicveronese@gmail.com>

Also fix bracketed IPv6 hosts with an explicit port: the URL parser
did not advance past the port digits, so ws://[::1]:8080/path produced
the request path /:8080/path (reported in review).
Harden the connection lifecycle:

- Complete the close handshake when our close frame was sent and wslay
  disabled further reads (oversized message, protocol violation): surface
  the close code we sent instead of waiting for a peer reply that can
  never be processed, which previously wedged the connection until the
  timeout fired; propagate the close reason received from the peer
- Enforce a 5 second close phase timeout so a peer never replying its
  close frame cannot hang a CLOSING connection indefinitely
- Make close() idempotent while closing and turn close() during the
  connecting or handshake phase into an immediate abort (close event
  with status 1006, full teardown)
- Defer wslay_event_send() when send(), ping() or close() are invoked
  from within event callbacks to avoid calling into wslay re-entrantly
  from its own receive context
- Stop polling for readability once wslay disabled reads and handle the
  resulting empty poll set by deregistering the descriptor
- Route in-session socket errors such as ECONNRESET through the
  abnormal closure path (error event, close code 1006)

Fix defects uncovered while building the test suite:

- Fix a reference counting error where the connection resource was
  shared between the C context and the script instead of being properly
  ucv_get() accounted, causing a premature free and heap corruption
  when the resource was used after teardown
- Fix a poll registration regression that dropped readability events
  during the handshake response phase, making every connection time out
- Fix the ws://host:port/ hostname off-by-one introduced with IPv6
  literal support
- Repair a mangled is-callable check in on() which caused event
  callbacks to be silently dropped

Signed-off-by: Nicolò Veronese <nicveronese@gmail.com>

Additional review fixes:

- Never flush queued frames, and never tear the connection down,
  while still inside the wslay_event_recv() call stack: the deferred
  flush previously ran from the message callback epilogue which is
  still below wslay_event_recv(), so a peer sending a message and
  resetting the connection while the handler invoked send() or
  close() caused a use-after-free of the wslay context (remotely
  triggerable, found with ASAN)
- Reject on() calls on torn down connections instead of dereferencing
  a NULL resource handle
- Always emit a final close event with status 1006 after fatal send
  errors, matching the abrupt reset behaviour
Introduce tests/cram/test_websocket.t covering module loading,
handshake header validation, text and binary echo, ping/pong,
server and client initiated close handshakes (including close
reason propagation), fragmented message reassembly, oversized
frame rejection (close 1009), message flood processing without
loss, abrupt TCP resets, wrong Sec-WebSocket-Accept and non-101
handshake replies as well as URL validation.

Provide tests/cram/fixtures/ws-fixture.c, a small scripted
RFC 6455 server (independent implementation, no wslay) exposing
scenarios through the request path. The fixture target and the
test file are only built and registered when WEBSOCKET_SUPPORT
is enabled.

Also add complete jsdoc for the connect() function.

Signed-off-by: Nicolò Veronese <nicveronese@gmail.com>

Add regression tests for the review findings: IPv6 literal with
port request path, on() after connection teardown and send() from
a message callback across a peer reset (fixture /msgreset
scenario).
@blogic

blogic commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

have a look at https://github.com/blogic/uwsc. it does nto depend on an external websocket library and is derived/based ont he websocket server code that @jow- wrote.

@hitech95

hitech95 commented Sep 3, 2026

Copy link
Copy Markdown
Author

have a look at https://github.com/blogic/uwsc. it does nto depend on an external websocket library and is derived/based ont he websocket server code that @jow- wrote.

I completly missed that repo! I searched for ucode prefix and havent seen that! Have to do some tests on my integration and see if it is indeed a perfect replacement.
I leave this a draft at this point, i'll close it once I did some verification!

@blogic

blogic commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

@hitech95 if anything is missing let me know we can extend uwsc. I have been using the uwsc code base for several projects over the last months and it is ultra stable for me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants