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.

CallbackWhenCan reject / disconnect
init/2TCP accepted, before the RTMP handshake{:reject, reason} — e.g. refuse an IP
handle_connect/2client sent connect (app, tcUrl, ...){:reject, reason, state}
handle_publish/3client sent publish (app, stream key){:reject, reason, state}
handle_metadata/2client sent onMetaData{:disconnect, reason, state}
handle_frame/2every audio and video frame{:disconnect, reason, state}
handle_disconnect/2the 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

Areav0.1LaterNot planned
Simple handshakeSupported
Digest (HMAC) handshakeNot planned (old Flash-era clients)
RTMPE (encrypted)Rejected
Chunk streams: fmt 0–3, extended timestamps, interleavingSupported
Protocol control: Set Chunk Size, Abort, Ack, Window Ack Size, Set Peer Bandwidth, User Control (Stream Begin, Ping)Supported
AMF0 commands and metadataSupported
AMF3Type 17/15 messages with an AMF0 body (the common case)Maybe
connect, releaseStream, FCPublish, createStream, publish, FCUnpublish, deleteStream, closeStreamSupported
onMetaData (with or without @setDataFrame), aggregate messagesSupported
H.264 videoSupported: decoder config (profile, level, SPS, PPS), NAL units, composition time
AAC audioSupported: AudioSpecificConfig, raw frames
MP3, Speex, PCM, Nellymoser, G.711, Sorenson H.263, VP6, Screen VideoPassed through unparsed
HEVC, AV1, VP9, Opus (Enhanced RTMP)Rejected with a clear errorPlanned
RTMPS (TLS)Terminate TLS in front of itPlanned
Publishing to other servers (client)Planned
Playback to subscribers (play)RejectedNot planned (use a media server)
Transcoding, HLS, DASHNot 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. See RTMP.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:

OptionDefault
:handlerrequiredYour RTMP.Handler module
:handler_options[]Passed to init/2 for each connection
:port19350 picks a free port; read it with RTMP.Listener.port/1
:acceptors10Acceptor processes
:max_connections16_384Concurrent connections
:limitssee belowKeyword 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):

LimitDefaultBounds
:max_chunk_size1 MiBLargest chunk size a client may set
:max_message_size8 MiBLargest single message (e.g. a keyframe)
:max_chunk_streams16Chunk stream IDs per connection
:max_buffered_bytes16 MiBTotal bytes in partially received messages
:idle_timeout30 sNo data after the handshake
:handshake_timeout10 sPer 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

  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.