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.
moqx is currently aligned to the draft-14 MOQ/WebTransport stack exposed by
moqtail-rs / moqtail. This project does not currently track whatever the
latest MOQT Internet-Draft happens to be.
The intended protocol baseline today is:
- 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-14 — WebTransport over HTTP/3
- draft-ietf-moq-transport-14 — Media over QUIC Transport
If the MOQT draft evolves beyond draft-14, moqx will only adopt newer wire
behavior once the project explicitly chooses to move and documents that change
(e.g. a future draft-17 support effort).
# mix.exs
{:moqx, "~> 0.6.1"}Release metadata:
- source: https://github.com/dmorn/moqx
- changelog: https://github.com/dmorn/moqx/blob/main/CHANGELOG.md
- license: MIT
Today moqx supports a single client-side path:
- explicit split roles only
- publisher sessions publish only
- subscriber sessions subscribe only
- WebTransport / MOQT 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)
- on the current moqtail relay, standalone fetch succeeds only for objects the relay already has in cache; upstream relay-to-publisher standalone fetch is not implemented yet
- optional helper-layer catalog publication via
MOQX.Helpers.publish_catalog/2andMOQX.Helpers.update_catalog/2 - optional helper-layer catalog retrieval via
MOQX.Helpers.fetch_catalog/2andMOQX.Helpers.await_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 the current client contract:
- relay/server listener APIs
- embedding or managing a relay from Elixir
- automatic subscription orchestration from a parsed catalog
moqx exposes:
MOQX— low-level core message-passing APIMOQX.Helpers— opt-in convenience helpers built on top ofMOQX
This split is intentional:
MOQXstays explicit, asynchronous, and low-levelMOQX.Helpersprovides small convenience flows built purely on publicMOQX- a future managed/stateful ergonomics layer may be added separately, but it is
not part of the
MOQXcore contract
If you want blocking waits, retries, buffering policy, or mailbox demultiplexing,
build them on top of the public message contracts instead of expecting hidden
state inside MOQX itself.
0.5.0 finalizes the low-level async core contract. If you are upgrading from
older 0.2.x–0.4.x APIs, the main changes are:
- connect is explicitly correlated:
connect/2,connect_publisher/2,connect_subscriber/2now return{:ok, connect_ref}- success/failure arrives later via typed async messages
- publish readiness is explicit:
publish/2returns{:ok, publish_ref}- the broadcast handle arrives only in
{:moqx_publish_ok, ...}
- publisher writes are lifecycle-gated:
- writes before downstream activation now return typed sync request errors instead of silently dropping
- subscribe/fetch lifecycle messages are typed structs now:
- old tuple-era contracts like
:moqx_subscribed,:moqx_track_ended,:moqx_fetch_started,:moqx_fetch_error, and generic async tuple errors have been replaced by typed families such as:moqx_subscribe_ok,:moqx_publish_done,:moqx_fetch_ok,:moqx_request_error, and:moqx_transport_error
- old tuple-era contracts like
- catalog helpers moved out of
MOQXcore intoMOQX.Helpers unsubscribe/1now culminates in{:moqx_publish_done, ...}rather than the older:moqx_track_endedtuple contract- subscribe timeout option is
delivery_timeout_ms:- draft-14
SUBSCRIBEuses MOQTDELIVERY_TIMEOUT(0x02) - the later-draft
RENDEZVOUS_TIMEOUTparameter is not part of the draft-14 stack used bymoqtail
- draft-14
- primary mix task names are now:
mix moqx.inspectmix moqx.roundtrip
The examples below reflect the stabilized 0.5.0 contract.
Connections are asynchronous and explicitly correlated.
connect_publisher/2, connect_subscriber/2, and connect/2 return
{:ok, connect_ref} immediately.
Later, the caller receives one of:
{:moqx_connect_ok, %MOQX.ConnectOk{ref: connect_ref, session: session, role: role, version: version}}{:moqx_request_error, %MOQX.RequestError{op: :connect, ref: connect_ref, ...}}{:moqx_transport_error, %MOQX.TransportError{op: :connect, ref: connect_ref, ...}}
There is no supported :both session mode.
{:ok, connect_ref} = MOQX.connect_publisher("https://relay.example.com")
publisher =
receive do
{:moqx_connect_ok, %MOQX.ConnectOk{ref: ^connect_ref, session: session}} -> session
{:moqx_request_error, %MOQX.RequestError{ref: ^connect_ref} = err} ->
raise "connect rejected: #{inspect(err)}"
{:moqx_transport_error, %MOQX.TransportError{ref: ^connect_ref} = err} ->
raise "connect transport failure: #{inspect(err)}"
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, publish_ref} = MOQX.publish(publisher, "alice")
broadcast =
receive do
{:moqx_publish_ok, %MOQX.PublishOk{ref: ^publish_ref, broadcast: broadcast}} -> broadcast
end
{:ok, _sub_ref} = MOQX.subscribe(subscriber, "alice", "video")If you need dynamic role selection:
{:ok, _ref} = MOQX.connect(url, role: :publisher)
{:ok, _ref} =
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 namespace registration is asynchronous and explicit.
publish/2 returns {:ok, publish_ref} immediately, and the broadcast is
usable only after :moqx_publish_ok:
{:ok, publish_ref} = MOQX.publish(publisher, "anon/demo")
broadcast =
receive do
{:moqx_publish_ok,
%MOQX.PublishOk{ref: ^publish_ref, broadcast: broadcast, namespace: "anon/demo"}} ->
broadcast
{:moqx_request_error, %MOQX.RequestError{op: :publish, ref: ^publish_ref} = err} ->
raise "publish rejected: #{inspect(err)}"
{:moqx_transport_error, %MOQX.TransportError{op: :publish, ref: ^publish_ref} = err} ->
raise "publish transport failure: #{inspect(err)}"
end
catalog_json = ~s({"version":1,"supportsDeltaUpdates":false,"tracks":[{"name":"video","role":"video"}]})
{:ok, catalog_track} = MOQX.Helpers.publish_catalog(broadcast, catalog_json)
:ok = MOQX.Helpers.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)
# lifecycle gating on the same handle:
{:error, %MOQX.RequestError{code: :track_closed}} = MOQX.write_frame(track, "frame-3")Write calls are explicitly lifecycle-gated (no silent drops):
{:error, %MOQX.RequestError{code: :track_not_active}}before downstream subscribe activation{:error, %MOQX.RequestError{code: :track_closed}}afterfinish_track/1
In moqtail-style relays, the publisher is responsible for publishing the
"catalog" track. The relay then forwards that catalog track downstream to
subscribers.
Use MOQX.Helpers.publish_catalog/2 for initial publication, then
MOQX.Helpers.update_catalog/2 for subsequent catalog objects:
{:ok, publish_ref} = MOQX.publish(publisher, "my-namespace")
broadcast =
receive do
{:moqx_publish_ok, %MOQX.PublishOk{ref: ^publish_ref, broadcast: broadcast}} -> broadcast
end
catalog_json =
~s({"version":1,"supportsDeltaUpdates":false,"tracks":[{"name":"video","role":"video"}]})
{:ok, catalog_track} = MOQX.Helpers.publish_catalog(broadcast, catalog_json)
:ok = MOQX.Helpers.update_catalog(catalog_track, catalog_json)Subscriptions are asynchronous and correlated by subscription handle.
subscribe/3,4 returns {:ok, handle} immediately.
subscribe/4 options:
delivery_timeout_ms-- draft-14 MOQTDELIVERY_TIMEOUTin milliseconds (encoded as parameter0x02onSUBSCRIBE).
moqx currently targets the draft-14 stack exposed by moqtail. Later MOQT
drafts introduce a separate RENDEZVOUS_TIMEOUT parameter, but that is not a
draft-14 wire parameter and is not exposed separately here.
The subscription message contract is:
{:moqx_subscribe_ok, %MOQX.SubscribeOk{handle, namespace, track_name}}{:moqx_track_init, %MOQX.TrackInit{handle, init_data, track_meta}}{:moqx_object, %MOQX.ObjectReceived{handle, object: %MOQX.Object{...}}}{:moqx_end_of_group, %MOQX.EndOfGroup{handle, group_id, subgroup_id}}{:moqx_publish_done, %MOQX.PublishDone{handle, status, ...}}{:moqx_request_error, %MOQX.RequestError{op: :subscribe, handle, ...}}{:moqx_transport_error, %MOQX.TransportError{op: :subscribe, handle, ...}}
{:ok, handle} = MOQX.subscribe(subscriber, "moqtail", "catalog", delivery_timeout_ms: 1_500)
receive do
{:moqx_subscribe_ok, %MOQX.SubscribeOk{handle: ^handle}} -> :ok
end
receive do
{:moqx_object, %MOQX.ObjectReceived{handle: ^handle, object: obj}} ->
IO.inspect({obj.group_id, byte_size(obj.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.
The typical flow for consuming live media from a moqtail relay:
{:ok, connect_ref} = MOQX.connect_subscriber("https://ord.abr.moqtail.dev")
subscriber =
receive do
{:moqx_connect_ok, %MOQX.ConnectOk{ref: ^connect_ref, session: session}} -> session
end
{:ok, catalog_ref} = MOQX.subscribe(subscriber, "moqtail", "catalog")
receive do
{:moqx_subscribe_ok, %MOQX.SubscribeOk{handle: ^catalog_ref}} -> :ok
end
catalog =
receive do
{:moqx_object, %MOQX.ObjectReceived{handle: ^catalog_ref, object: %{payload: payload}}} ->
{:ok, cat} = MOQX.Catalog.decode(payload)
cat
end
video = MOQX.Catalog.video_tracks(catalog) |> List.first()
{:ok, video_ref} = MOQX.subscribe_track(subscriber, "moqtail", video)
receive do
{:moqx_subscribe_ok, %MOQX.SubscribeOk{handle: ^video_ref}} -> :ok
endFor quick manual debugging, use the built-in inspection task:
mix moqx.inspect
# defaults to https://ord.abr.moqtail.dev and namespace moqtail
mix moqx.inspect --track 259
mix moqx.inspect --list-tracks-only
mix moqx.inspect --list-relay-presets
mix moqx.inspect --choose-relay --list-tracks-only
mix moqx.inspect --preset cloudflare-draft14-bbb --list-tracks-only
# Cloudflare moq-rs style catalogs use .catalog
mix moqx.inspect https://draft-14.cloudflare.mediaoverquic.com --namespace bbb --catalog-track .catalog --list-tracks-only
mix moqx.inspect https://draft-14.cloudflare.mediaoverquic.com --namespace bbb --no-fetch --list-tracks-onlyThe task will:
- connect as a subscriber,
- load catalog via fetch (with live-subscribe fallback when fetch has no objects or the relay has not cached the track yet),
- optionally apply a known relay preset (
--preset) or choose one interactively (--choose-relay), - try
"catalog"and then".catalog"unless--catalog-trackis set, - optionally skip fetch entirely with
--no-fetchand go straight to live subscribe, - 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.inspect for full options.
Tips:
--list-tracks-onlyis handy for scripting/discovery without subscribing.--list-relay-presetsprints known relay presets and example commands.--choose-relaylets you pick a known relay interactively and prints the exact reproduce command.--no-fetchis useful for relays that do not implement fetch yet.--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. - legacy aliases
mix moqx.moqtail.demoandmix moqx.e2e.pubsubstill work, but now print deprecation notices.
For a quick publisher+subscriber roundtrip against a relay, use:
mix moqx.roundtrip
# defaults to https://ord.abr.moqtail.dev
# Cloudflare draft-14 relay endpoints
mix moqx.roundtrip https://interop-relay.cloudflare.mediaoverquic.com:443 --timeout 20000
mix moqx.roundtrip https://draft-14.cloudflare.mediaoverquic.com --timeout 20000The task connects as both publisher and subscriber, publishes a test track, subscribes to it, waits for publisher track activation, and verifies the subscriber receives the expected payload.
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.
Important moqtail relay note: the current relay only serves standalone fetches
from its local track cache. In practice that means fetch works end-to-end for
objects the relay has already seen (for example after live delivery to a
subscriber), but it does not yet forward standalone fetch upstream to a
publisher on cache miss. On such a cache miss, moqx surfaces the relay reply
as a typed {:moqx_request_error, %MOQX.RequestError{op: :fetch, ...}} rather
than hanging silently.
The fetch message contract is:
{:moqx_fetch_ok, %MOQX.FetchOk{ref, namespace, track_name}}{:moqx_fetch_object, %MOQX.FetchObject{ref, group_id, object_id, payload}}{:moqx_fetch_done, %MOQX.FetchDone{ref}}{:moqx_request_error, %MOQX.RequestError{op: :fetch, ref, ...}}{:moqx_transport_error, %MOQX.TransportError{op: :fetch, ref, ...}}
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)
MOQX.Helpers.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}). Override the catalog track explicitly when
needed, for example track: ".catalog" for Cloudflare moq-rs style relays.
MOQX.Helpers.await_catalog/2 collects the fetch messages and decodes the
payload into an MOQX.Catalog struct in one call:
{:ok, ref} = MOQX.Helpers.fetch_catalog(subscriber)
{:ok, catalog} = MOQX.Helpers.await_catalog(ref)
{:ok, cf_ref} = MOQX.Helpers.fetch_catalog(subscriber, namespace: "bbb", track: ".catalog")
{:ok, cloudflare_catalog} = MOQX.Helpers.await_catalog(cf_ref)
catalog |> MOQX.Catalog.video_tracks() |> Enum.map(& &1.name)
#=> ["259", "260"]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.
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...
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"]
- Rust toolchain (
rustup) - Elixir / Erlang
mix deps.get
mix testFor an explicit split between fast checks and integration coverage:
mix cimix ciruns formatting, Credo, and non-integration tests
Integration tests are run separately with mix test.integration, against a
relay you start yourself. For local development, the intended workflow is:
scripts/generate_integration_certs.sh .tmp/integration-certs
export MOQX_RELAY_CACERTFILE=.tmp/integration-certs/ca.pem
export MOQX_EXTERNAL_RELAY_URL=https://127.0.0.1:4433
docker compose -f docker-compose.integration.yml up -d relay
mix test.integrationThis keeps the relay running across repeated test runs, which is faster and simpler during local integration-test loops.
You can override relay version independently from the locally compiled moqtail
library by setting MOQX_RELAY_IMAGE, for example:
MOQX_RELAY_IMAGE=ghcr.io/moqtail/relay:sha-190e502 \
docker compose -f docker-compose.integration.yml up -d relaySet a digest-pinned reference for strict reproducibility:
MOQX_RELAY_IMAGE='ghcr.io/moqtail/relay:sha-190e502@sha256:36c929b71140a83158da383721f1d59f199a9f643ab5d033910258f5aa2903ee' \
docker compose -f docker-compose.integration.yml up -d relaymix test.integration expects a relay URL and trusted CA path via environment.
By default the tests use https://127.0.0.1:4433; set
MOQX_EXTERNAL_RELAY_URL and MOQX_RELAY_CACERTFILE if you are using a
non-default setup.
When finished locally, tear the relay down with:
docker compose -f docker-compose.integration.yml down --remove-orphansSecure 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.
MIT