A small RTMP ingest server for Elixir. No framework, no FFmpeg, no native code.
Point OBS, FFmpeg or a phone encoder at it, decide in one callback whether to accept the stream key, and receive H.264 and AAC frames with timestamps.
Status: v0.1. The support table below says what works today. Anything not marked "Supported" does not work yet.
It is a protocol library, not a media server: no playback, no relaying to
subscribers, no HLS or transcoding. See examples/flv_saver
for what building on top of it looks like.
Why this exists
I needed live video ingest for a production streaming app built on Elixir. I searched Hex and found nothing I could build on, so I wrote the handshake, chunk parsing and AMF handling myself, then spent months finding out what real encoders and unreliable mobile networks do to a connection.
This library is that work, rewritten as a package other people can use. It is built for the cases that break naive implementations: small or changing chunk sizes, chunks from different streams interleaved, timestamps past the 24-bit boundary or wrapping at 32 bits, malformed or hostile input, and clients that stop sending without closing.
Quick start
# mix.exs
{:rtmp, "~> 0.1"}defmodule MyIngest do
use RTMP.Handler
@impl true
def handle_publish("live", key, state) do
if valid_key?(key), do: {:ok, state}, else: {:reject, :bad_key, state}
end
def handle_publish(_other_app, _key, state), do: {:reject, :unknown_app, state}
@impl true
def handle_frame(%RTMP.Frame{type: :video, dts: _dts, nalus: _nalus}, state) do
# forward to your packager, recorder or relay
{:ok, state}
end
def handle_frame(%RTMP.Frame{type: :audio}, state), do: {:ok, state}
defp valid_key?(key), do: key == "my_key"
end
# in your supervision tree
{RTMP.Listener, port: 1935, handler: MyIngest}Test it without a camera:
ffmpeg -re -stream_loop -1 -i sample.mp4 -c copy -f flv rtmp://localhost:1935/live/my_key
In OBS: Settings → Stream → Service: Custom, server
rtmp://your-host:1935/live, stream key my_key.
The handler
handle_publish/3 and handle_frame/2 are required. The rest have defaults
when you use RTMP.Handler. There's deliberately no default for
handle_publish/3: deciding which stream keys are valid is the one thing
every server must do on purpose.
| Callback | When | Can reject / disconnect |
|---|---|---|
init/2 | TCP accepted, before the RTMP handshake | {:reject, reason} — e.g. refuse an IP |
handle_connect/2 | client sent connect (app, tcUrl, ...) | {:reject, reason, state} |
handle_publish/3 | client sent publish (app, stream key) | {:reject, reason, state} |
handle_metadata/2 | client sent onMetaData | {:disconnect, reason, state} |
handle_frame/2 | every audio and video frame | {:disconnect, reason, state} |
handle_disconnect/2 | the connection ended, with the reason | — |
A frame (RTMP.Frame) carries type, codec, dts and cts (so
RTMP.Frame.pts/1), keyframe?, sequence_header?, the codec data, parsed
config for sequence headers, nalus for H.264, and the raw payload — which
is exactly an FLV tag body, ready to write to a file.
Callbacks run in the connection's own process, so self() inside a callback
is the connection. Keep it to kick a publisher later with
RTMP.disconnect(pid, reason).
Every way a connection can end reaches handle_disconnect/2 with a reason:
:closed, :unpublished, :idle_timeout, :handshake_timeout,
{:rejected, reason}, {:disconnected, reason}, {:protocol_error, reason},
{:handler_error, exception}, :shutdown or {:socket_error, reason}.
What is supported
| Area | v0.1 | Later | Not planned |
|---|---|---|---|
| Simple handshake | Supported | ||
| Digest (HMAC) handshake | Not planned (old Flash-era clients) | ||
| RTMPE (encrypted) | Rejected | ||
| Chunk streams: fmt 0–3, extended timestamps, interleaving | Supported | ||
| Protocol control: Set Chunk Size, Abort, Ack, Window Ack Size, Set Peer Bandwidth, User Control (Stream Begin, Ping) | Supported | ||
| AMF0 commands and metadata | Supported | ||
| AMF3 | Type 17/15 messages with an AMF0 body (the common case) | Maybe | |
connect, releaseStream, FCPublish, createStream, publish, FCUnpublish, deleteStream, closeStream | Supported | ||
onMetaData (with or without @setDataFrame), aggregate messages | Supported | ||
| H.264 video | Supported: decoder config (profile, level, SPS, PPS), NAL units, composition time | ||
| AAC audio | Supported: AudioSpecificConfig, raw frames | ||
| MP3, Speex, PCM, Nellymoser, G.711, Sorenson H.263, VP6, Screen Video | Passed through unparsed | ||
| HEVC, AV1, VP9, Opus (Enhanced RTMP) | Rejected with a clear error | Planned | |
| RTMPS (TLS) | Terminate TLS in front of it | Planned | |
| Publishing to other servers (client) | Planned | ||
Playback to subscribers (play) | Rejected | Not planned (use a media server) | |
| Transcoding, HLS, DASH | Not planned (see examples) |
Behaviour under bad conditions
- A timeout on every handshake step, and an idle timeout once connected.
- Hard limits on chunk size, message size, open chunk streams, and total bytes held in partially received messages.
- A slow handler cannot make memory grow without bound (see Design notes).
- A malformed or hostile client closes its own connection and nothing else. Parsers return errors on bad input; they never raise.
Telemetry events:
[:rtmp, :connection, :start | :stop | :rejected],[:rtmp, :publish, :start | :stop],[:rtmp, :bytes, :in],[:rtmp, :protocol, :error]. Stream keys are never included. SeeRTMP.Telemetry.
Tested with: FFmpeg (libavformat 62.3) — H.264 + AAC, AAC only, MP3, and timestamps past the 24-bit boundary, captured byte-for-byte as test fixtures. Not yet tested against OBS or Larix directly; OBS uses librtmp's command sequence, which matches FFmpeg's.
Configuration
RTMP.Listener options:
| Option | Default | |
|---|---|---|
:handler | required | Your RTMP.Handler module |
:handler_options | [] | Passed to init/2 for each connection |
:port | 1935 | 0 picks a free port; read it with RTMP.Listener.port/1 |
:acceptors | 10 | Acceptor processes |
:max_connections | 16_384 | Concurrent connections |
:limits | see below | Keyword list or %RTMP.Limits{} |
:transport_options | [] | Passed to :gen_tcp, e.g. [ip: {127, 0, 0, 1}] |
:name | — | Registered name |
Per-connection limits (RTMP.Limits):
| Limit | Default | Bounds |
|---|---|---|
:max_chunk_size | 1 MiB | Largest chunk size a client may set |
:max_message_size | 8 MiB | Largest single message (e.g. a keyframe) |
:max_chunk_streams | 16 | Chunk stream IDs per connection |
:max_buffered_bytes | 16 MiB | Total bytes in partially received messages |
:idle_timeout | 30 s | No data after the handshake |
:handshake_timeout | 10 s | Per handshake step |
Design notes
Backpressure. Frames are passed to your handler synchronously, in the connection's own process, and no more data is read from the socket until your callback returns. If your handler is slow, the publisher is slowed down by TCP flow control. There's no internal queue, so there's nothing to grow. If you'd rather not slow the publisher, hand frames to another process yourself and decide what to drop.
Failure isolation. Each connection is its own process (on
ThousandIsland). A protocol error,
a limit breach, a timeout or an exception in your handler closes that
connection cleanly — handle_disconnect/2 still runs and the exception is
logged — and nothing else.
Determinism. Frames that fully arrived before a protocol error are delivered before the connection closes, however TCP happened to split the bytes.
Known limitations
- Ingest only: no client, no
play, no relaying. - No RTMPS — terminate TLS in front (nginx
stream, HAProxy, a load balancer). - No Enhanced RTMP codecs; they're rejected with a reason rather than handed to you undecodable.
- Only H.264 and AAC are parsed; other legacy codecs are passed through.
- Simple handshake only.
- No AMF3 values (AMF3-typed messages with an AMF0 body work).
- One publish per connection, which is what every encoder does.
- Timestamps are RTMP's 32-bit milliseconds and wrap after about 49.7 days.
- Clients must honour the negotiated chunk size. A client that writes messages larger than the chunk size without continuation headers is breaking the spec, and its stream is rejected as malformed.
- Extended timestamps on continuation chunks are detected by value, as SRS and nginx-rtmp do, to support encoders that omit them. If a continuation chunk's first four payload bytes happen to equal the timestamp, they'd be misread.
Examples
examples/flv_saver: write every published stream to an FLV file (about 70 lines).examples/hls_bridge/: hand frames to a packager. (planned)
Compared with other Elixir options
- membrane_rtmp_plugin: part of the Membrane framework, relies on FFmpeg. Choose it if you are already building a Membrane pipeline.
- ex_rtmp: server and client in one package.
- eml_rtmp family, rtmpmsg: lower-level building blocks.
Choose this library if you want a plain Elixir ingest server with a single handler behaviour, strict limits and telemetry, and no dependency on a framework. (Check the claims above against each project's current README before publishing.)
Testing
mix test
Unit tests for every protocol layer; property-based tests (StreamData) for
AMF0 round-trips, chunk encode/parse round-trips at random chunk sizes and
split points, randomly interleaved chunk streams, and fuzzing with random
bytes; socket-level tests replaying captured FFmpeg publishes in randomly
sized writes; and tests for every limit, rejection path, handler failure,
telemetry event and backpressure. CI also publishes a live FFmpeg test
pattern to the FLV saver example and checks the file with ffprobe.
Roadmap
- v0.1: ingest server (this README).
- v0.2: RTMPS, Enhanced RTMP codecs.
- v0.3: publisher (client) mode.
- Outside this repo: publishers for React Native and Tauri.
License
MIT — see LICENSE.