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:
- RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport
- RFC 9001 — Using TLS to Secure QUIC
- RFC 9002 — QUIC Loss Detection and Congestion Control
- RFC 9114 — HTTP/3
- RFC 9221 — QUIC DATAGRAM
- RFC 9297 — HTTP Datagrams and the Capsule Protocol
- draft-ietf-webtrans-http3 — WebTransport over HTTP/3
- draft-ietf-moq-transport — Media over QUIC Transport
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.0"}Release metadata:
- source: https://github.com/dmorn/moqx
- changelog: https://github.com/dmorn/moqx/blob/main/CHANGELOG.md
- license: MIT
Stable supported client contract
Today moqx supports a single client-side path:
-
explicit split roles only
- publisher sessions publish only
- subscriber sessions subscribe only
- WebTransport over QUIC (Draft 14)
- broadcasts, tracks, and frame delivery
-
live subscription via SUBSCRIBE with
FilterType::LatestObject - raw fetch for retrieving track objects by range (subscriber sessions only)
-
publisher-side catalog publication helpers via
publish_catalog/2andupdate_catalog/2 -
raw catalog retrieval via
fetch_catalog/2andawait_catalog/2 -
CMSF catalog parsing and track discovery via
MOQX.Catalog -
relay authentication through the connect URL query, using
?jwt=... -
path-rooted relay authorization, where the connect URL path must match the token
root -
minimal client TLS controls:
- verification is on by default
tls: [verify: :insecure]is an explicit local-development escape hatchtls: [cacertfile: "/path/to/rootCA.pem"]trusts a custom root CA
Not planned:
- merged publisher/subscriber sessions
Out of scope for v0.1:
- relay/server listener APIs
- embedding or managing a relay from Elixir
- automatic subscription orchestration from a parsed catalog
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:
{:moqx_connected, session}on success{:error, reason}if the async connect attempt fails
The stable, intended connect surface is:
MOQX.connect_publisher/1,2MOQX.connect_subscriber/1,2MOQX.connect/2with requiredrole: :publisher | :subscriber
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)}"
endFor 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:
:role- required,:publisheror:subscriber:tls- optional TLS controls:verify: :verify_peer | :insecure- defaults to:verify_peercacertfile: "/path/to/rootCA.pem"- trust a custom root CA PEM
Notes:
-
relay authentication currently rides on the URL itself: pass the JWT as
?jwt=... -
relay authorization is path-rooted: the connect URL path must match the token
root - listener/server APIs remain out of scope
-
TLS verification is enabled by default;
tls: [verify: :insecure]is a local-development escape hatch only -
the
cacertfileoption is intended for private/local roots; default verification otherwise uses system/native roots - synchronous option/usage problems raise or return immediately; network/runtime failures are delivered asynchronously as process messages
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:
delivery_timeout_ms-- MOQT DELIVERY TIMEOUT parameter (0x02) in milliseconds.
The supported subscription message contract is:
{:moqx_subscribed, handle, namespace, track_name}when the subscription becomes active{:moqx_track_init, handle, init_data, track_meta}once per subscription{:moqx_frame, handle, group_id, payload}for each object{:moqx_track_ended, handle}when the track finishes cleanly, or afterunsubscribe/1is acknowledged by the relay{:moqx_error, handle, reason}for asynchronous subscription/runtime failures
{: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)}")
endMix 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-onlyThe task will:
- connect as a subscriber,
- load catalog via fetch (with live-subscribe fallback when fetch has no objects),
-
prompt you to choose a track (or use
--track <name>), -
subscribe and print live stats each interval:
-
PRFT latency (or
n/aif unavailable), -
bandwidth (
B/sandkbps), - groups/sec,
- objects/sec.
-
PRFT latency (or
Use mix help moqx.moqtail.demo for full options.
Tips:
--list-tracks-onlyis handy for scripting/discovery without subscribing.--show-rawprints full per-track raw catalog maps.-
pass
--timeout <ms>to auto-stop after a bounded runtime. -
the default relay (
https://ord.abr.moqtail.dev) has an online demo player athttps://abr.moqtail.dev/demo, which is useful for quickly double-checking relay availability outside ofmoqx.
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 20000The 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:
{:moqx_fetch_started, ref, namespace, track_name}when the fetch begins{:moqx_fetch_object, ref, group_id, object_id, payload}for each object{:moqx_fetch_done, ref}when the fetch completes{:moqx_fetch_error, ref, reason}on failure
Options:
priority-- integer0..255(default0)group_order--:original,:ascending, or:descending(default:original)start--{group_id, object_id}(default{0, 0})end--{group_id, object_id}(default: open-ended)
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:
rootputfor publish permissionsgetfor subscribe permissionsclusterwhen needed by relay clusteringiatexp
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:
-
publish-only token:
put: [""],get: [] -
subscribe-only token:
put: [],get: [""] -
full room access:
put: [""], get: [""] -
narrower access can use rooted suffixes like
put: ["alice"],get: ["viewers"]
Local development
Prerequisites
-
Rust toolchain (
rustup) - Elixir / Erlang
Run tests
mix deps.get
mix testFor an explicit split between fast checks and integration coverage:
mix ci
mix test.integrationmix ciruns formatting, Credo, and non-integration testsmix test.integrationruns the deterministic integration suite (excludes:public_relay_livetests)-
run live public-relay coverage explicitly with:
mix test --include integration --include public_relay_live
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 ::1Then 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