Packages

Format-agnostic socket library with pluggable packet framing, a reusable drain engine, and multi-protocol transport support.

Current section

Files

Jump to
xsockets README.md
Raw

README.md

# XSockets
Ported from (and deprecates) [xturn-sockets](https://github.com/Lazarus404/xturn-sockets)
An Elixir library for listening, dialing, and reading whole messages on UDP, TCP,
TLS, DTLS, and (optionally) SCTP -- without baking in STUN, TURN, RTP, or SIP.
Those protocols live in *your* app. XSockets just handles the pipes and the
"when is a full packet ready?" part.
Home: [https://github.com/Lazarus404/xsockets](https://github.com/Lazarus404/xsockets)
## What problem this solves
If you have built a real-time server, you have probably written the same GenServer
loop more than once: bind a socket, read, figure out where one message ends and
the next begins, call your logic, write a reply, repeat.
XSockets turns that into three plugs:
1. **Transport** -- how bytes move (`:udp`, `:tcp`, `:tls`, `:dtls`)
2. **Framing** -- how you spot a whole message (`:raw`, `:length_prefixed`)
3. **Handler** -- what *you* do with each message
A shared **Engine** drains every complete packet from each read before it asks
the socket for more. That keeps control traffic (handshakes, requests) tidy.
High-rate media can skip the engine and use `Transport.UDP.open_relay/2` instead.
## Start here
### 1. Write a handler
```elixir
defmodule MyApp.Handler do
@behaviour XSockets.Handler
@impl true
def handle_connect(conn), do: {:ok, conn.assigns}
@impl true
def handle_packet(packet, _meta, conn, state) do
IO.inspect({packet, conn.client_ip, conn.client_port})
{:ok, state}
end
end
```
### 2. Serve (or dial)
```elixir
# UDP -- each datagram is already a whole message (:raw)
{:ok, pid} =
XSockets.serve(MyApp.Handler,
transport: :udp,
ip: {0, 0, 0, 0},
port: 3478
)
# TCP -- messages are length-prefixed (2-byte size header by default)
{:ok, pid} =
XSockets.serve(MyApp.Handler,
transport: :tcp,
ip: {0, 0, 0, 0},
port: 3478
)
# Call out as a client (needs :ip and :port)
{:ok, conn} =
XSockets.dial(MyApp.Handler,
transport: :tcp,
ip: {127, 0, 0, 1},
port: 3478
)
```
`serve/2` and `dial/2` are the friendly front door. They pick sensible framing
defaults and call `listen/1` or `Client.connect/1` for you. Pass `:accumulator`
or `:pipeline` when you want to override framing. Reach for `listen/1` when you
need SCTP, explicit modules, or multi-step pipelines.
### Try the examples
```bash
mix run examples/echo_udp.exs
```
1. `examples/echo_udp.exs` -- UDP echo
2. `examples/echo_tcp.exs` -- TCP with length prefixes
3. `examples/echo_tls.exs` -- TLS with a self-signed cert
4. `examples/echo_client.exs` -- `serve` + `dial` together
5. `examples/pipeline_inline.exs` -- two-step pipeline (advanced)
6. `examples/udp_relay.exs` -- Engine vs `open_relay/2` (advanced)
## Main modules
Everyday tools:
- `XSockets` -- `serve/2`, `dial/2`, `listen/1`, `listen_many/1`
- `XSockets.Client` -- outbound TCP/TLS
- `XSockets.Handler` -- your per-packet logic
- `XSockets.Engine` -- the shared "read, frame, dispatch" loop
- `Transport.*` -- UDP / TCP / TLS / DTLS / optional SCTP I/O
- `Accumulator.*` -- framing (`Raw`, `LengthPrefixed`, `Reorder`)
When you need more structure:
- `Pipeline` -- name several handler stages and route between them
- `Connection` -- one process per TCP/TLS/DTLS stream
- `DatagramServer` -- one process per UDP listen socket
- `Acceptor` -- accept loop for connection-oriented transports
- `SockSupervisor` / `TierSupervisor.*` -- supervision for connections and async work
- `RateLimit` (+ `FixedWindow`, `PerConnection`) -- optional Engine rate limiting
- `Sctp.Association` / `Sctp.Association.Server` / `Sctp.Dcep` -- WebRTC data channels
(SCTP *inside* DTLS; different from IP-level `Transport.SCTP`)
STUN, TURN, RTP, SIP framing stays in *your* `Accumulator` / `Handler` modules.
This library only ships the generic plumbing.
## Features (in plain terms)
- One shape of API across UDP, TCP, TLS, DTLS, and optional SCTP
- Pluggable framing: "is there a whole packet yet?"
- Full drain: every complete message in a read is handled before the next read
- Backpressure on streams via a write queue and `{:busy, state}`
- Optional Telemetry under `[:xsockets, ...]`
**Control vs media:** put request-shaped traffic through the Engine and your
Handler. Put high-rate media on `UDP.open_relay/2` (or a raw socket) so it does
not share that loop.
**Two kinds of SCTP (easy to mix up):**
- `Transport.SCTP` + `Sctp.Listener` -- SCTP over IP (OTP `:gen_sctp`)
- `Sctp.Association` -- SCTP over a DTLS byte pipe (WebRTC data channels; needs
optional `{:ex_sctp, "~> 0.1"}` and a Rust toolchain to compile)
**Useful config** (on `:xsockets` or your host `:config_app`):
- `engine_rate_limit` (default `true`) -- rate-limit when `conn.client_ip` is set
- `rate_limiter` -- default `FixedWindow` (datagrams only); use `PerConnection`
if you also want to gate TCP/TLS chunks
- `pool_on_overflow` -- `:drop` (default) or `:inline_fallback` for busy `:pool` tiers
- `send_timeout_ms` (default `5000`) -- timeout before Engine replies
- `write_queue_max` (default `32`) -- outbound queue size on streams
- `datagram_write_on_error` -- `:retry_peer` (default) or `:drop` on UDP send failure
## Production configuration
Defaults lean toward "fail closed under load" (pool overflow drops work;
`FixedWindow` only watches datagrams). Opt in when you need stricter delivery:
```elixir
# Do not drop work on a busy :pool tier -- run it inline instead
config :xsockets, pool_on_overflow: :inline_fallback
# or per tier: on_overflow: :inline_fallback
# Also rate-limit TCP/TLS Engine chunks
config :xsockets, rate_limiter: XSockets.RateLimit.PerConnection
```
Async `:task` / `:pool` work is best-effort: a crashed worker can lose what was
in its mailbox, and `:drop` throws away work when the pool is full. Prefer
`:inline` for small control graphs, or `:inline_fallback` when a pool tier must
not lose messages.
## Installation
```elixir
def deps do
[
{:xsockets, "~> 1.0.0"},
{:telemetry, "~> 1.0"}
# optional: {:ex_sctp, "~> 0.1"}
]
end
```
## Advanced
Skip this until `serve` / `dial` are not enough. It covers multi-step pipelines,
reordering, async dispatch, and related knobs.
### Reordering
Sometimes packets arrive out of order and you need them sorted before your
handler sees them. `Accumulator.Reorder` wraps another accumulator, tags each
whole packet with a key (for example an RTP sequence number), and holds
stragglers until the window is contiguous -- or until a time/size limit says
"give up and flush."
```elixir
accumulator: {
XSockets.Accumulator.Reorder,
name: :rtp,
inner: XSockets.Accumulator.LengthPrefixed,
inner_opts: [header_size: 2],
key_fun: &MyApp.RTP.sequence/2,
window: 32,
max_delay_ms: 100,
on_overflow: :flush_oldest
}
```
Tunable keys (`window`, `max_delay_ms`, `on_overflow`, `enabled`) merge with
library defaults and app config:
```elixir
config :xsockets,
config_app: :my_app,
reorder: [
rtp: [window: 32, max_delay_ms: 150]
]
config :my_app,
reorder: [
rtp: [window: 48]
]
```
Precedence: library defaults < `config :xsockets, :reorder` <
`config :config_app, :reorder` < keys in the accumulator spec.
`key_fun`, `inner`, and `name` always come from code -- never from config.
If an accumulator may hold packets across reads (waiting on a missing sequence
number), set `tick_interval_ms` on `Connection` or `DatagramServer` so the
Engine can flush on a timer even when no new data arrives.
### Pipelines
A pipeline is a small graph of stages. The root stage sees the wire; a handler
can `{:descend, :other_tier, payload, state}` to hand work to another stage:
```elixir
defmodule MyApp.Pipeline do
use XSockets.Pipeline
tier :root,
accumulator: {XSockets.Accumulator.LengthPrefixed, header_size: 2},
handler: MyApp.Handlers.Stun
tier :rtp,
accumulator: XSockets.Accumulator.Raw,
handler: MyApp.Handlers.Rtp
end
{:ok, pid} =
XSockets.listen(
transport: XSockets.Transport.TCP,
ip: {0, 0, 0, 0},
port: 3478,
pipeline: MyApp.Pipeline,
assigns: %{}
)
```
See `examples/pipeline_inline.exs`.
Worth knowing:
- A crash inside a descended tier is caught, emits `[:xsockets, :tier_crashed]`,
and the outer tier keeps going.
- `{:close, state}` from *any* tier closes the whole connection (or stops that
datagram's processing).
- Only `:root` gets `handle_connect/1`. Descended tiers start with `nil` state
on first visit. On disconnect, every activated tier gets `handle_disconnect/2`.
### Dispatch strategies
Each tier can say how its work runs:
- **`:inline`** (default) -- same process; safest for "must not drop"
- **`:task`** -- async supervised worker; root keeps moving; best-effort if the
worker crashes
- **`:pool`** -- like `:task` with a size cap. Overflow `:drop` (default) or
`:inline_fallback`. For control-critical tiers, prefer `:inline_fallback`.
Start the supervisors (or let `XSockets.Application` do it):
```elixir
{:ok, _} = XSockets.SockSupervisor.start_link()
{:ok, _} = XSockets.TierSupervisor.Task.start_link()
{:ok, _} = XSockets.TierSupervisor.Pool.start_link()
```
### Bounded buffers
Built-in accumulators accept `:max_size`. Overflow shows up once as
`{:error, :buffer_overflow, acc}`; the Engine emits `[:xsockets, :frame_error]`
and keeps draining.
### Telemetry
`XSockets.Telemetry.emit/3` plus your own `:telemetry` handlers.
`Telemetry.attach_handlers/0` installs log/counter handlers for events the
library actually emits (listeners, packets, TLS handshake, rate limit, tiers,
send errors).
Common events when `:telemetry_enabled` is true:
- `[:xsockets, :tier_dispatch]` / `:tier_crashed` / `:tier_pool_saturated`
- `[:xsockets, :udp_sessions_evicted]` / `:frame_error`
- `[:xsockets, :message_sent]` / `:send_error`
- `[:xsockets, :listener_started]` / `:rate_limit_exceeded` / `:ssl_handshake_*`
### Configuration
Point `:config_app` at your host app to override defaults:
```elixir
config :xsockets,
config_app: :my_app,
buffer_size: 262_144,
listener_buffer_size: 4_194_304,
ssl_handshake_timeout: 10_000,
rate_limit_enabled: true,
telemetry_enabled: true
config :my_app,
buffer_size: 131_072
```
Lookup order: `config :config_app` -> `config :xsockets` -> built-in default.
For TLS/DTLS certs, pass `certfile` / `keyfile` in listen opts, or set
`config :xsockets, certs: [...]`. Prefer
`Transport.TLS.security_opts(:server)` or `:mutual_tls` when building option lists.
## Testing
```bash
mix compile --warnings-as-errors
mix test
mix dialyzer
```
CI runs the same three checks.
Optional Linux SCTP proof (when your host has no SCTP):
```bash
docker compose -f docker-compose.sctp.yml run --rm xsockets-sctp
```
## Changelog
See [CHANGELOG.md](CHANGELOG.md).
## License
Apache 2.0 -- see [LICENSE.md](LICENSE.md).