moqx is an Elixir Media over QUIC library.
It provides a QUIC transport boundary backed by quicer for building MOQT implementations in Elixir. The transport boundary keeps protocol code independent from the concrete QUIC backend and allows tests to use deterministic support transports.
moqx currently implements Cloudflare's deployed MOQT draft-14 protocol over
native QUIC. Additional protocol implementations are deferred until this path
is ready for production consumers such as Membrane plugins.
Core references:
- 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
# mix.exs
{:moqx, "~> 0.7.1"}Protocol selection is explicit; the endpoint never selects an implementation implicitly. Cloudflare's public Big Buck Bunny catalog can be requested with:
{:ok, client} =
MOQX.connect("moqt://draft-14.cloudflare.mediaoverquic.com:443",
protocol: :cloudflare_draft_14
)
catalog_track = %MOQX.TrackRef{namespace: ["bbb"], track: ".catalog"}
{:ok, subscription} = MOQX.subscribe(client, catalog_track)
receive do
{:moqx, ^client,
%MOQX.Event.CatalogReceived{catalog: %MOQX.Catalog{} = catalog}} ->
catalog.tracks
endThis path uses native QUIC with ALPN moq-00, negotiates MOQT draft-14,
subscribes with LargestObject, and decodes the CMSF catalog delivered on a
subgroup stream. It does not use FETCH.
Catalog tracks can be subscribed directly. Delivered objects retain their subscription, group, subgroup, object, and priority coordinates:
{:ok, video} = MOQX.Catalog.select_h264(catalog)
{:ok, subscription} = MOQX.subscribe(client, MOQX.Catalog.Track.track_ref(video))
receive do
{:moqx, ^client,
%MOQX.Event.ObjectReceived{
object: %MOQX.Object{subscription: ^subscription} = object
}} ->
object.payload
endFor CMAF H.264, MOQX.CMAF.capture/4 subscribes to the advertised
initialization and media tracks, orders received objects by their protocol
coordinates, writes a fragmented MP4 atomically, and unsubscribes its temporary
subscriptions:
{:ok, report} =
MOQX.CMAF.capture(client, catalog, "/tmp/cloudflare-bbb.mp4",
objects: 120,
timeout: 30_000
)The runnable external example performs the complete flow:
mix run scripts/cloudflare_h264_capture.exs /tmp/cloudflare-bbb.mp4 120
ffprobe -v error -show_streams /tmp/cloudflare-bbb.mp4
ffmpeg -y -i /tmp/cloudflare-bbb.mp4 -map 0:v:0 -c:v copy \
-bsf:v h264_mp4toannexb -an -f h264 /tmp/cloudflare-bbb.h264
ffmpeg -v error -f h264 -i /tmp/cloudflare-bbb.h264 -f null -MOQX.unsubscribe/2 sends the selected protocol's unsubscribe message;
MOQX.close/2 closes the connection. Relay rejections are delivered as
MOQX.Event.SubscriptionFailed, while MOQX.Event.SubscriptionDone is emitted
only after every stream advertised by PUBLISH_DONE has been processed or the
subscription's :delivery_timeout has elapsed.
All application-facing output uses typed MOQX.Event.* structs inside the
stable {:moqx, client, event} envelope. By default events go to the process
that calls MOQX.connect/2; shared connection owners can choose a router:
{:ok, client} =
MOQX.connect(endpoint,
protocol: :cloudflare_draft_14,
events_to: router_pid
)Downstream projects can run hermetic protocol tests with the packaged in-memory transport. It must be selected explicitly and is never chosen by production facade code:
{:ok, network} = MOQX.Testing.Transport.start_network()
MOQX.connect("moqt://localhost:443",
protocol: :cloudflare_draft_14,
transport: {MOQX.Testing.Transport, network: network, profile: :draft_14}
)Publishing uses the same explicitly selected client. Applications declare a namespace and tracks, then supply protocol-neutral objects; Cloudflare request IDs, track aliases, and inbound relay subscriptions remain implementation details:
{:ok, publication} = MOQX.publish(client, ["live", "camera-1"])
{:ok, video} =
MOQX.add_track(client, publication, "video.m4s", retention: :live)
:ok =
MOQX.publish_object(client, video, %MOQX.Object{
group_id: 42,
subgroup_id: 0,
object_id: 0,
publisher_priority: 127,
payload: fragment
})
:ok = MOQX.finish_publication(client, publication)Retention is application policy: :live discards objects when no subscriber
is active, :latest retains one object for catalog or initialization tracks,
and :all replays bounded static content.
Inbound subscriptions are accepted automatically by default. A publisher can instead inspect, authorize, and provision each request before deciding it:
{:ok, publication} =
MOQX.publish(client, ["live", "camera-1"],
inbound_subscriptions: :controlled,
subscription_decision_timeout: 5_000,
max_pending_subscriptions: 128
)
receive do
{:moqx, ^client,
%MOQX.Event.PublicationSubscriptionRequested{request: request}} ->
{:ok, video} =
MOQX.add_track(client, publication, request.track.track, retention: :live)
:ok = MOQX.accept_subscription(client, request, video)
endMOQX.reject_subscription/3 accepts a protocol-neutral
MOQX.SubscriptionRejection. Pending requests are connection-scoped, bounded
by the configured count and timeout, and are invalidated by unsubscribe,
publication termination, or connection closure. Request events preserve
priority, forward state, group order, all four draft-14 filters, repeated
authorization parameters, delivery timeout, and unknown extensions.
Controlled acceptance supports ascending delivery. A request for descending
delivery remains pending and accept_subscription/4 returns
{:error, :unsupported_group_order}; the application should reject it with
:not_supported. Publisher-selected order defaults to ascending and can be
confirmed explicitly with group_order: :ascending in the acceptance options.
MOQX.CMAF.publish_file/3 prepares a fragmented MP4 as a CMSF .catalog, an
initialization track, and retained media fragments:
{:ok, published} =
MOQX.CMAF.publish_file(client, "/tmp/input.mp4",
namespace: ["live", "camera-1"]
)Managed relay credentials are explicit caller input. The credential value is wrapped so both its value and the resulting sensitive wire actions have redacted inspection:
{:ok, client} =
MOQX.connect(endpoint,
protocol: :cloudflare_draft_14,
authorization: MOQX.Secret.new(token)
)MOQX encodes that value using draft-14's standard AUTHORIZATION TOKEN parameter. Token acquisition, permissions, storage, and rotation remain relay and application concerns; MOQX does not read process or application configuration for credentials.
The manual publisher/subscriber roundtrip accepts a token file so the token is not placed in shell history. Omit it for Cloudflare's public relay:
mix run scripts/cloudflare_h264_publish.exs /tmp/input.mp4 \
--endpoint moqt://draft-14.cloudflare.mediaoverquic.com:443 \
--namespace moqx-test/unique-publisher \
--output /tmp/roundtrip.mp4 \
--timeout 120000
# For a managed relay, additionally pass:
# --authorization-file /path/to/temporarily-mounted-token
ffprobe -v error -show_streams /tmp/roundtrip.mp4
ffmpeg -v error -i /tmp/roundtrip.mp4 -map 0:v:0 -f null -mix deps.get
mix test
mix ciDefault tests are fast and hermetic. Real QUIC checks are tagged as ExUnit integration tests and are excluded by default.
The public Cloudflare interop check is independently selectable and depends on the availability of an external service:
mix test --only integration test/integration/cloudflare_catalog_test.exsThe repo-owned Cloudflare draft-14 roundtrip runs both MOQX and a real relay in Docker. It publishes a catalog and media object through the public API, subscribes through a second public client, verifies delivery, and exercises graceful publication completion:
scripts/run_moq_rs_integration.shThe harness builds Cloudflare's moq-rs draft-ietf-moq-transport-14 branch at
the immutable revision 69302d3dc2422e93b8a1d62f853a6759aa9e5468. Do not
replace that pin with main: upstream main has moved to a later MOQT draft
and no longer negotiates the draft-14 moq-00 ALPN. The MOQX test runner joins
the Compose network directly so the QUIC path is identical on Docker Desktop
and Linux CI rather than depending on host UDP forwarding.
ExUnit never starts Docker. The script owns Compose startup and cleanup, and
the same script is the Cloudflare draft-14 relay roundtrip CI job. Future
relay variants should add separately pinned Compose services,
tagged public-API tests, and runner scripts following this boundary; they must
not add an implicit protocol fallback or overload this Cloudflare test.
To run the caller-managed QUIC integration harness:
docker compose -f docker-compose.integration.yml up -d --wait
mix test --only integrationExUnit does not start Docker. Stop the harness when finished:
docker compose -f docker-compose.integration.yml downThe harness provisions self-signed certificates under
.tmp/integration-certs/ (via scripts/gen-loopback-certs.sh) and runs the
repo-owned reference QUIC server from bench/quicprobe on UDP port 4433. The
generated CA/server certificate is valid for ~100 years — it only authenticates
a localhost QUIC handshake, so it is intentionally long-lived to avoid expiry
friction. To (re)generate the loopback certificates outside the harness:
scripts/gen-loopback-certs.sh .tmp/integration-certsThe script is idempotent: it reuses an existing certificate unless it is missing or nearly expired.
For manual debugging, run the reference CLI directly:
go run ./bench/quicprobe server --addr :4433 \
--cert .tmp/integration-certs/server.pem \
--key .tmp/integration-certs/server-key.pem \
--alpn moqx-test
go run ./bench/quicprobe client --addr 127.0.0.1:4433 \
--ca .tmp/integration-certs/ca.pem \
--alpn moqx-test \
--bidi-echo helloFor reference stream-pressure experiments, the client can emit structured
quicprobe-v1 JSON:
go run ./bench/quicprobe client --addr 127.0.0.1:4433 \
--ca .tmp/integration-certs/ca.pem \
--alpn moqx-test \
--json \
--stream-direction bidirectional \
--stream-count 2 \
--payload-size 1200 \
--payload-count 100MIT