RTMP

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

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

Examples

Compared with other Elixir options

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

  1. v0.1: ingest server (this README).
  2. v0.2: RTMPS, Enhanced RTMP codecs.
  3. v0.3: publisher (client) mode.
  4. Outside this repo: publishers for React Native and Tauri.

License

MIT — see LICENSE.