NAME

EV::Websockets - WebSocket client/server using libwebsockets and EV

SYNOPSIS

use EV;
use EV::Websockets;

my $ctx = EV::Websockets::Context->new(loop => EV::default_loop);

my $conn = $ctx->connect(
    url        => 'ws://example.com/ws',
    on_connect => sub {
        my ($conn) = @_;
        $conn->send("Hello, WebSocket!");
    },
    on_message => sub {
        my ($conn, $data) = @_;
        print "Got: $data\n";
    },
    on_close   => sub {
        my ($conn, $code, $reason) = @_;
        print "Closed: $code " . ($reason // "") . "\n";
    },
    on_error   => sub {
        my ($conn, $err) = @_;
        print "Error: $err\n";
    },
);

EV::run;

DESCRIPTION

EV::Websockets provides WebSocket client and server functionality using the libwebsockets C library integrated with the EV event loop.

This module uses libwebsockets' foreign loop integration to run within an existing EV event loop, making it suitable for applications already using EV.

Important: a context with no active listeners or connections may spin an internal idle watcher, preventing other EV watchers (timers, I/O) from firing. Always create a listener ($ctx->listen(...)) or connection ($ctx->connect(...)) before entering EV::run, or destroy the context when not in use.

CLASSES

EV::Websockets::Context

Manages the libwebsockets context and event loop integration.

new(%options)

Create a new context.

my $ctx = EV::Websockets::Context->new(
    loop       => EV::default_loop,  # optional, defaults to EV::default_loop
    ssl_cert   => 'client.pem',      # optional, for mTLS client certificates
    ssl_key    => 'client-key.pem',  # required if ssl_cert is set
    ssl_ca     => 'ca.pem',          # optional CA chain
    proxy      => '192.168.1.1',     # optional HTTP proxy host
    proxy_port => 8080,              # optional proxy port (default: 1080)
    ssl_init   => 0,                 # optional, skip OpenSSL global init
);

If proxy is not specified, the module reads https_proxy, http_proxy, or all_proxy from the environment. Pass proxy => "" to suppress auto-detection.

ssl_init controls whether libwebsockets initializes OpenSSL globals. By default, initialization happens once on the first context. Pass ssl_init => 0 when coexisting with another TLS library (e.g. Feersum/picotls) to avoid reinitializing shared OpenSSL state.

connect(%options)

Create a new WebSocket connection.

my $conn = $ctx->connect(
    url              => 'wss://example.com/ws',
    protocol         => 'chat',              # optional subprotocol
    headers          => { Authorization => 'Bearer token' },
    ssl_verify       => 1,                   # 0 to disable TLS verification
    max_message_size => 1048576,             # optional, 0 = unlimited
    connect_timeout  => 5.0,                 # optional, seconds
    on_connect  => sub { my ($conn, $headers) = @_; ... },
    on_message  => sub { my ($conn, $data, $is_binary) = @_; ... },
    on_close    => sub { my ($conn, $code, $reason) = @_; ... },
    on_error    => sub { my ($conn, $err) = @_; ... },
    on_pong     => sub { my ($conn, $payload) = @_; ... },
    on_drain    => sub { my ($conn) = @_; ... },
);

Returns an EV::Websockets::Connection object.

on_message receives complete reassembled messages; fragmented frames are buffered internally up to max_message_size. For backwards compatibility a fourth argument $is_final is also passed but is always 1.

connect_timeout sets a deadline (in seconds) for the WebSocket handshake. If the connection is not established within this time, on_error fires with "connect timeout" and the connection is closed.

$headers in on_connect is a hashref of response headers from the server (Set-Cookie, Content-Type, Server, Sec-WebSocket-Protocol, and when available Location, WWW-Authenticate).

on_drain fires from the writeable callback when the send queue empties. It will not fire if close() has already been queued - once closing is in progress the connection short-circuits to teardown without emitting drain. If you need to act after the queue empties, do so before calling close(), or rely on on_close instead.

listen(%options)

Create a WebSocket listener. Returns the port number being listened on (useful if port 0 was requested).

my $port = $ctx->listen(
    port             => 0,          # 0 to let OS pick a port
    name             => 'server',   # optional vhost name (default: 'server')
    protocol         => 'chat',     # optional WebSocket subprotocol
    ssl_cert         => 'cert.pem', # optional, enables TLS
    ssl_key          => 'key.pem',  # required if ssl_cert is set
    ssl_ca           => 'ca.pem',   # optional CA chain
    max_message_size => 1048576,    # optional, 0 = unlimited
    headers          => { 'Set-Cookie' => 'session=abc123' }, # response headers
    on_handshake => sub { my ($headers) = @_; return { 'X-Custom' => 'val' } },
    on_connect  => sub { my ($conn, $headers) = @_; ... },
    on_message  => sub { my ($conn, $data, $is_binary) = @_; ... },
    on_close    => sub { my ($conn, $code, $reason) = @_; ... },
    on_error    => sub { my ($conn, $err) = @_; ... },
    on_pong     => sub { my ($conn, $payload) = @_; ... },
    on_drain    => sub { my ($conn) = @_; ... },
);

protocol sets the WebSocket subprotocol name advertised by the server vhost. The vhost name default is reserved and will croak if used.

$headers in on_connect is a hashref of client request headers (Path, Host, Origin, Cookie, Authorization, Sec-WebSocket-Protocol, User-Agent, X-Forwarded-For). Path is the request URI (e.g., /chat).

headers is an optional hashref of headers to inject into the HTTP upgrade response (e.g., Set-Cookie).

on_handshake fires before the 101 response is sent (at LWS_CALLBACK_FILTER_PROTOCOL_CONNECTION). It receives a hashref of request headers (same keys as on_connect). Return a hashref to inject per-connection response headers into the upgrade response. Return a false value (undef, 0, "") to reject the connection (the client receives a 403).

connections

Returns a list of Connection objects whose state is "connected" or "closing" (i.e. the WebSocket handshake completed and the underlying wsi still exists). Conns still in "connecting" and conns already "closed"/"destroyed" are omitted.

my @conns = $ctx->connections;
$_->send("broadcast!") for @conns;

adopt(%options)

Adopt an existing IO handle (socket).

my $conn = $ctx->adopt(
    fh               => $socket_handle,
    initial_data     => $already_read_bytes, # optional pre-read data
    max_message_size => 1048576,
    on_connect => sub { my ($conn, $headers) = @_; ... },
    on_message => sub { my ($conn, $data, $is_binary) = @_; ... },
    on_close   => sub { my ($conn, $code, $reason) = @_; ... },
    on_error   => sub { my ($conn, $err) = @_; ... },
    on_pong    => sub { my ($conn, $payload) = @_; ... },
    on_drain   => sub { my ($conn) = @_; ... },
);

Once adopted, libwebsockets takes ownership of the file descriptor. The module holds a reference to the Perl handle until the connection is destroyed, preventing premature fd closure. $headers in on_connect is always undef for adopted connections.

If you already read data from the socket (e.g., the HTTP upgrade request), pass it via initial_data so lws can process the handshake.

EV::Websockets::Connection

Represents a WebSocket connection.

send($data)

Queue a text frame. Croaks if the connection is not open.

send_binary($data)

Queue a binary frame. Croaks if the connection is not open.

send_ping([$payload])

Queue a Ping frame. $payload is optional; if supplied it is silently truncated to 125 bytes per RFC 6455 §5.5. Croaks if the connection is not open.

send_pong([$payload])

Queue a Pong frame. Same payload rules as send_ping. Most peers send Pong automatically in response to Ping; you only need this to send an unsolicited Pong (e.g. as a one-way keepalive).

send_fragment($data, $is_binary = 0, $is_final = 1)

Send one fragment of a streaming message. The first call starts a new fragmented message (text or binary per $is_binary); subsequent calls send continuation frames. Set $is_final true on the last fragment.

$conn->send_fragment("part1", 0, 0);   # text, not final
$conn->send_fragment("part2", 0, 0);   # continuation, not final
$conn->send_fragment("part3", 0, 1);   # continuation, final

Use this only if you need to interleave outbound writes with other I/O while streaming a single message. For ordinary sends, prefer send/send_binary.

send_queue_size

Returns the number of payload bytes currently queued for sending (excludes WebSocket framing overhead). Useful for backpressure monitoring; pair with on_drain to gate further sends.

stash

Returns a hashref for storing arbitrary per-connection metadata. The hashref is lazily created on first access and lives until the connection is freed.

$conn->stash->{user_id} = 42;
my $uid = $conn->stash->{user_id};

get_protocol

Returns the negotiated Sec-WebSocket-Protocol value, or undef if no subprotocol was negotiated or the connection is closed.

peer_address

Returns the peer's IP address as a printable string (IPv4 dotted-quad or IPv6 colon notation, no brackets, no port), or undef if unavailable.

close([$code = 1000], [$reason])

Initiate a clean WebSocket close. Sends a Close frame with $code (default 1000, normal closure) and an optional UTF-8 $reason (truncated by lws to fit the frame). Pending sends are drained first, then the connection is torn down and on_close fires.

This is a no-op (does not croak) if the connection is already closed, closing, or destroyed. It is also a no-op while the connection is still in the "connecting" state - calling close() before the handshake completes does not cancel the in-flight connect; use connect_timeout to bound the handshake instead.

pause_recv

Stop reading frames from this connection (TCP flow control). New incoming frames will back up in the kernel's socket buffer until resume_recv is called. Silently does nothing on a closed or destroyed connection.

resume_recv

Resume receiving after pause_recv. Silently does nothing on a closed or destroyed connection.

is_connected

Returns true while state is "connected".

is_connecting

Returns true while state is "connecting". Returns false once the connection is established, closing, closed, or destroyed.

state

Returns the current state as one of:

"connecting" - TCP/TLS handshake or HTTP upgrade in progress
"connected" - open and ready to send/receive
"closing" - close() has been called; pending sends still draining
"closed" - the underlying wsi is gone but the Perl object is still alive
"destroyed" - the C struct has been freed (further method calls will croak)

DEBUGGING

EV::Websockets::_set_debug(1);

Enables verbose debug output from both the module and libwebsockets. In tests, gate on $ENV{EV_WS_DEBUG}:

EV::Websockets::_set_debug(1) if $ENV{EV_WS_DEBUG};

FEERSUM INTEGRATION

Adopt WebSocket connections from a Feersum PSGI server via psgix.io:

use Feersum;
use EV::Websockets;

my $ctx = EV::Websockets::Context->new;
my $feersum = Feersum->endjinn;
$feersum->set_psgix_io(1);

$feersum->psgi_request_handler(sub {
    my $env = shift;
    return [400,[],[]] unless ($env->{HTTP_UPGRADE}//'') =~ /websocket/i;

    my $io = $env->{'psgix.io'};

    # Reconstruct HTTP upgrade for lws
    my $path = $env->{REQUEST_URI} // '/';
    my $hdr = "GET $path HTTP/1.1\r\n";
    for (sort keys %$env) {
        next unless /^HTTP_(.+)/;
        (my $h=$1) =~ s/_/-/g;
        $hdr .= "$h: $env->{$_}\r\n";
    }
    $hdr .= "\r\n";

    $ctx->adopt(fh => $io, initial_data => $hdr,
        on_message => sub { $_[0]->send($_[1]) },  # echo
    );
    return;
});

See also eg/feersum_native.pl and eg/feersum_psgi.pl for full examples.

BENCHMARKS

The bench/ directory contains latency and throughput benchmarks.

# Echo round-trip latency (native client + native server)
perl bench/latency.pl

# Throughput (messages/sec)
perl bench/throughput.pl

# Comparison with AnyEvent::WebSocket and Net::WebSocket::EVx
perl bench/compare.pl

Typical results on Linux (localhost, 1000 round-trips, 64-byte payload):

EV::Websockets          ~10us avg,  ~97k msg/s  (C/libwebsockets)
Net::WebSocket::EVx     ~10us avg,  ~96k msg/s  (C/wslay)
Mojolicious             ~83us avg,  ~12k msg/s  (Pure Perl)
Net::Async::WebSocket  ~141us avg,   ~7k msg/s  (Pure Perl)

URL FORMATS

connect accepts ws:// (plaintext) and wss:// (TLS) URLs:

ws://host[:port]/path
wss://host[:port]/path
ws://[2001:db8::1]:9001/         # IPv6 (brackets required)

The default port is 80 for ws:// and 443 for wss://. Userinfo (user:pass@) in the URL is not parsed; pass HTTP auth via the headers option instead.

ERRORS AND EXCEPTIONS

Methods on "EV::Websockets::Connection" fall into two groups:

Send / accessors that croak when the connection is gone

send, send_binary, send_ping, send_pong, send_fragment, and stash croak with "Connection has been destroyed" or "Connection is not open" if invoked after the connection has closed or been DESTROYed. Wrap in eval { ... } if you may race connection teardown.

Lifecycle / control that silently no-op

close, pause_recv, and resume_recv return silently when the connection is already closed or destroyed. This makes them safe to call from cleanup paths without guarding.

User-supplied callbacks (on_connect, on_message, on_close, on_error, on_pong, on_drain, on_handshake) are invoked under G_EVAL: a die inside a callback is caught, warned, and the connection continues. on_error is itself wrapped, so a die inside on_error will not recurse.

SEE ALSO

EV, Alien::libwebsockets, libwebsockets, Net::WebSocket::EVx, AnyEvent::WebSocket::Client

AUTHOR

vividsnow

LICENSE

This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.