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.

Spec references (RFCs and drafts)

moqx (through moqtail-rs / moqtail) is aligned with the current MOQ/WebTransport stack. At the time of writing, that means a mix of published RFCs and active IETF drafts:

For MOQ-specific behavior, treat the active MOQ transport draft and moqtail interoperability behavior as the practical reference until final RFC publication.

Installation

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

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, _sub_ref} = 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")

catalog_json = ~s({"version":1,"supportsDeltaUpdates":false,"tracks":[{"name":"video","role":"video"}]})
{:ok, catalog_track} = MOQX.publish_catalog(broadcast, catalog_json)
:ok = MOQX.update_catalog(catalog_track, catalog_json)

{: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)

Publisher-side catalog publication

In moqtail-style relays, the publisher is responsible for publishing the "catalog" track. The relay then forwards that catalog track downstream to subscribers.

Use publish_catalog/2 for initial publication, then update_catalog/2 for subsequent catalog objects:

{:ok, broadcast} = MOQX.publish(publisher, "my-namespace")

catalog_json =
  ~s({"version":1,"supportsDeltaUpdates":false,"tracks":[{"name":"video","role":"video"}]})

{:ok, catalog_track} = MOQX.publish_catalog(broadcast, catalog_json)
:ok = MOQX.update_catalog(catalog_track, catalog_json)

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, handle} immediately, then messages arrive in the caller process correlated by handle. The handle is an opaque subscription resource (appears as a reference() to Elixir code).

subscribe/4 accepts subscription options. For catalog-driven flows, subscribe_track/3,4 is a convenience wrapper around subscribe/4.

subscribe/4 options:

The supported subscription message contract is:

{:ok, handle} =
  MOQX.subscribe(
    subscriber,
    "moqtail",
    "catalog",
    delivery_timeout_ms: 1_500
  )

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

receive do
  {:moqx_track_init, ^handle, _init_data, _track_meta} -> :ok
end

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

:ok = MOQX.unsubscribe(handle)

unsubscribe/1 is idempotent and fire-and-forget: it sends MOQ Unsubscribe to the relay and removes local subscription state. If the handle is garbage-collected before unsubscribe/1 is called, the same cleanup runs automatically — so short-lived subscribing processes do not need to unsubscribe explicitly.

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, catalog_ref} = MOQX.subscribe(subscriber, "moqtail", "catalog")

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

receive do
  {:moqx_track_init, ^catalog_ref, _init_data, _track_meta} -> :ok
end

catalog =
  receive do
    {:moqx_frame, ^catalog_ref, _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, video_ref} = MOQX.subscribe_track(subscriber, "moqtail", video)

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

receive do
  {:moqx_track_init, ^video_ref, init_data, track_meta} ->
    IO.inspect({byte_size(init_data || <<>>), track_meta}, label: "video init")
end

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

Mix task: interactive relay debug

For quick manual debugging, use the built-in Mix task:

mix moqx.moqtail.demo
# defaults to https://ord.abr.moqtail.dev and namespace moqtail
mix moqx.moqtail.demo --track 259
mix moqx.moqtail.demo --list-tracks-only

The task will:

  1. connect as a subscriber,
  2. load catalog via fetch (with live-subscribe fallback when fetch has no objects),
  3. prompt you to choose a track (or use --track <name>),
  4. subscribe and print live stats each interval:
    • PRFT latency (or n/a if unavailable),
    • bandwidth (B/s and kbps),
    • groups/sec,
    • objects/sec.

Use mix help moqx.moqtail.demo for full options.

Tips:

Mix task: relay pub/sub end-to-end smoke test

For a quick publisher+subscriber roundtrip against a relay, use:

mix moqx.e2e.pubsub
# defaults to https://ord.abr.moqtail.dev

# Cloudflare draft-14 relay endpoints
mix moqx.e2e.pubsub https://interop-relay.cloudflare.mediaoverquic.com:443 --timeout 20000
mix moqx.e2e.pubsub https://draft-14.cloudflare.mediaoverquic.com --timeout 20000

The task connects as both publisher and subscriber, publishes a test track, subscribes to it, and verifies the subscriber receives the expected payload.

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