moqx

Elixir bindings for Media over QUIC (MOQ) via Rustler NIFs on top of moqtail-rs.

Status: early client library with a deliberately narrow, documented support contract.

Installation

# mix.exs
{:moqx, "~> 0.2.0"}

Release metadata:

Stable supported client contract

Today moqx supports a single client-side path:

Not planned:

Out of scope for v0.1:

Public API

The intended API is the single MOQX module.

Connect

Connections are asynchronous. connect_publisher/1, connect_publisher/2, connect_subscriber/1, and connect_subscriber/2 return :ok immediately, then the caller receives exactly one connect result message:

The stable, intended connect surface is:

There is no supported :both session mode.

:ok = MOQX.connect_publisher("https://relay.example.com")

publisher =
  receive do
    {:moqx_connected, session} -> session
    {:error, reason} -> raise "publisher connect failed: #{inspect(reason)}"
  end

:ok = MOQX.connect_subscriber("https://relay.example.com")

subscriber =
  receive do
    {:moqx_connected, session} -> session
    {:error, reason} -> raise "subscriber connect failed: #{inspect(reason)}"
  end

For an auth-enabled relay, pass the token in the URL query:

jwt = "eyJhbGciOiJIUzI1NiIs..."

:ok =
  MOQX.connect_publisher(
    "https://relay.example.com/room/123?jwt=#{jwt}",
    tls: [cacertfile: "/path/to/rootCA.pem"]
  )

When you connect to a rooted URL like /room/123, relay authorization is rooted at that path. Publish and subscribe paths can stay relative to that root:

{:ok, broadcast} = MOQX.publish(publisher, "alice")
:ok = MOQX.subscribe(subscriber, "alice", "video")

If you need dynamic role selection:

:ok = MOQX.connect(url, role: :publisher)

:ok =
  MOQX.connect_subscriber(
    "https://relay.internal.example/anon",
    tls: [cacertfile: "/path/to/rootCA.pem"]
  )

Supported connect options:

Notes:

Publish

{:ok, broadcast} = MOQX.publish(publisher, "anon/demo")
{:ok, track} = MOQX.create_track(broadcast, "video")

:ok = MOQX.write_frame(track, "frame-1")
:ok = MOQX.write_frame(track, "frame-2")
:ok = MOQX.finish_track(track)

Subscribe

Subscriptions are asynchronous and use FilterType::LatestObject with forward=true, which means the relay delivers the most recent object and then forwards new objects as they arrive. This is the standard pattern for live media consumption from moqtail-style relays.

subscribe/3 returns :ok immediately, then messages arrive in the caller process.

The supported subscription message contract is:

:ok = MOQX.subscribe(subscriber, "moqtail", "catalog")

receive do
  {:moqx_subscribed, "moqtail", "catalog"} -> :ok
end

receive do
  {:moqx_frame, group_id, payload} ->
    IO.inspect({group_id, byte_size(payload)}, label: "catalog object")
end

Catalog-driven subscription

The typical flow for consuming live media from a moqtail relay:

# Connect
:ok = MOQX.connect_subscriber("https://ord.abr.moqtail.dev")

subscriber =
  receive do
    {:moqx_connected, session} -> session
  end

# Subscribe to the catalog track to discover available media
:ok = MOQX.subscribe(subscriber, "moqtail", "catalog")

receive do
  {:moqx_subscribed, _, _} -> :ok
end

catalog =
  receive do
    {:moqx_frame, _group, payload} ->
      {:ok, cat} = MOQX.Catalog.decode(payload)
      cat
  end

# Pick a video track and subscribe
video = MOQX.Catalog.video_tracks(catalog) |> List.first()

:ok = MOQX.subscribe(subscriber, "moqtail", video.name)

receive do
  {:moqx_subscribed, _, _} -> :ok
end

# Receive live video frames
receive do
  {:moqx_frame, group_id, payload} ->
    IO.puts("video frame: group=#{group_id} size=#{byte_size(payload)}")
end

Fetch

Fetch retrieves raw track objects by range from a subscriber session. fetch/4 returns {:ok, ref} immediately, then delivers messages to the caller's mailbox correlated by ref.

The fetch message contract is:

Options:

fetch_catalog/2 is a convenience wrapper that fetches the first catalog object with sensible defaults (namespace "moqtail", track "catalog", range {0,0}..{0,1}).

await_catalog/2 collects the fetch messages and decodes the payload into an MOQX.Catalog struct in one call:

{:ok, ref} = MOQX.fetch_catalog(subscriber)
{:ok, catalog} = MOQX.await_catalog(ref)

catalog |> MOQX.Catalog.video_tracks() |> Enum.map(& &1.name)
#=> ["259", "260"]

Catalog parsing and track discovery

MOQX.Catalog decodes raw CMSF catalog bytes (UTF-8 JSON) into an Elixir struct with track discovery helpers:

{:ok, catalog} = MOQX.Catalog.decode(payload)

MOQX.Catalog.tracks(catalog)           # all tracks
MOQX.Catalog.video_tracks(catalog)     # video tracks only
MOQX.Catalog.audio_tracks(catalog)     # audio tracks only
MOQX.Catalog.get_track(catalog, "259") # by exact name

# Track fields are accessed directly on the struct
track = hd(MOQX.Catalog.video_tracks(catalog))
track.name      #=> "259"
track.codec     #=> "avc1.42C01F"
track.packaging #=> "cmaf"
track.role      #=> "video"

Each track also carries a raw map with all original JSON fields for forward compatibility with catalog properties not yet modeled as struct keys.

Relay authentication

Upstream relay auth currently expects JWTs in the jwt query parameter, and the URL path must match the token root. moqx intentionally keeps this model in the URL rather than introducing a separate public auth API. Follow the implementation claim names, not older prose that still says pub / sub.

Use these claims:

A typical authenticated URL looks like:

https://localhost:4443/room/123?jwt=eyJhbGciOiJIUzI1NiIs...

Minting relay-compatible tokens with JOSE

Add JOSE to your project if you want to mint tokens from Elixir:

# mix.exs
{:jose, "~> 1.11"}

Example using a symmetric oct JWK:

jwk =
  JOSE.JWK.from(%{
    "alg" => "HS256",
    "key_ops" => ["sign", "verify"],
    "kty" => "oct",
    "k" => Base.url_encode64("replace-with-a-strong-shared-secret", padding: false),
    "kid" => "relay-dev-root"
  })

now = System.system_time(:second)

claims = %{
  "root" => "room/123",
  "put" => [""],
  "get" => [""],
  "iat" => now,
  "exp" => now + 3600
}

{_jws, jwt} =
  jwk
  |> JOSE.JWT.sign(%{"alg" => "HS256", "kid" => "relay-dev-root", "typ" => "JWT"}, claims)
  |> JOSE.JWS.compact()

url = "https://localhost:4443/room/123?jwt=#{jwt}"

A few practical patterns:

Local development

Prerequisites

Run tests

mix deps.get
mix test

For an explicit split between fast checks and integration coverage:

mix ci
mix test.integration

Local relay TLS

Secure verification is the default in moqx.

For local development against a relay with self-signed certificates, either configure a trusted local certificate chain or opt into tls: [verify: :insecure] explicitly.

For example, if you already have a local CA PEM for your relay:

MOQX.connect_publisher(
  "https://localhost:4443",
  tls: [cacertfile: "/absolute/path/to/rootCA.pem"]
)

For the best local developer experience, use mkcert to install a local development CA and generate a trusted localhost certificate:

mkcert -install
mkcert -cert-file localhost.pem -key-file localhost-key.pem localhost 127.0.0.1 ::1

Then configure the relay to use file-based TLS, for example:

[server]
listen = "[::]:4443"
tls.cert = ["/absolute/path/to/localhost.pem"]
tls.key = ["/absolute/path/to/localhost-key.pem"]

With that setup, default moqx connections can verify the relay certificate without falling back to verify: :insecure.

License

MIT