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

moqx is aligned to the draft-14 MOQ/WebTransport stack exposed by moqtail-rs. It does not automatically track newer IETF drafts; version bumps are explicit.

Installation

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

Supported client contract

Not planned: merged publisher/subscriber sessions, relay/server listener APIs, automatic catalog-driven subscription orchestration.

Public API

All network operations are asynchronous and correlated by a ref or handle returned immediately. Errors arrive as typed process messages (%MOQX.RequestError{}, %MOQX.TransportError{}).

Usage

Connect

{:ok, ref} = MOQX.connect_publisher("https://relay.example.com?jwt=<token>",
  tls: [cacertfile: "/path/to/rootCA.pem"])

publisher =
  receive do
    {:moqx_connect_ok, %MOQX.ConnectOk{ref: ^ref, session: s}} -> s
    {:moqx_request_error, %MOQX.RequestError{ref: ^ref} = e} -> raise inspect(e)
    {:moqx_transport_error, %MOQX.TransportError{ref: ^ref} = e} -> raise inspect(e)
  end

connect_subscriber/2 and connect/2 (with role: :publisher | :subscriber) follow the same pattern.

Publish

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

broadcast =
  receive do
    {:moqx_publish_ok, %MOQX.PublishOk{ref: ^publish_ref, broadcast: b}} -> b
    {:moqx_request_error, %MOQX.RequestError{ref: ^publish_ref} = e} -> raise inspect(e)
  end

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

{:ok, track} = MOQX.create_track(broadcast, "video")
:ok = MOQX.write_frame(track, "frame-1")
:ok = MOQX.finish_track(track)

For datagram delivery use write_datagram/3 with explicit group_id, object_id, etc. Writes are lifecycle-gated and return typed errors before activation or after finish_track/1.

Subscribe

{:ok, handle} = MOQX.subscribe(subscriber, "my-namespace", "video", delivery_timeout_ms: 1_500)

receive do: ({:moqx_subscribe_ok, %MOQX.SubscribeOk{handle: ^handle}} -> :ok)

receive do
  {:moqx_object, %MOQX.ObjectReceived{handle: ^handle, object: obj}} ->
    IO.inspect(obj.payload)
  {:moqx_publish_done, %MOQX.PublishDone{handle: ^handle}} ->
    :done
end

:ok = MOQX.unsubscribe(handle)

Subscribe message contract: :moqx_subscribe_ok, :moqx_track_init, :moqx_object, :moqx_end_of_group, :moqx_publish_done, :moqx_request_error, :moqx_transport_error.

%MOQX.Object{transport: :subgroup | :datagram} indicates the delivery path.

Fetch

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

video = catalog |> MOQX.Catalog.video_tracks() |> List.first()

fetch/4 options: priority, group_order (:original | :ascending | :descending), start/end as {group_id, object_id}. Message contract: :moqx_fetch_ok, :moqx_fetch_object, :moqx_fetch_done, :moqx_request_error, :moqx_transport_error.

Note: the moqtail relay serves standalone fetches from its local cache only. Cache misses surface as %MOQX.RequestError{op: :fetch}.

Catalog

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

MOQX.Catalog.tracks(catalog)
MOQX.Catalog.video_tracks(catalog)
MOQX.Catalog.audio_tracks(catalog)
MOQX.Catalog.get_track(catalog, "259")

Each track exposes a raw map for forward compatibility with unmodeled catalog fields.

Relay authentication

Pass JWTs as ?jwt=... in the connect URL. The URL path must match the token root claim.

Claims: root, put (publish), get (subscribe), cluster, iat, exp.

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

Mix tasks

# Live relay inspection and stats
mix moqx.inspect
mix moqx.inspect --track 259 --list-tracks-only
mix moqx help moqx.inspect   # full options

# Publisher+subscriber roundtrip smoke test
mix moqx.roundtrip
mix moqx.roundtrip https://relay.example.com --timeout 20000

Local development

Prerequisites: Rust toolchain (rustup), Elixir/Erlang.

mix deps.get
mix test          # unit tests
mix ci            # format + credo + unit tests

Integration tests require a local relay:

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.integration

Keep the relay running across test runs for faster iteration. If you regenerate certs or hit invalid peer certificate: BadSignature, recreate the container:

docker compose -f docker-compose.integration.yml down --remove-orphans
docker compose -f docker-compose.integration.yml up -d relay

Override the relay image with MOQX_RELAY_IMAGE=ghcr.io/moqtail/relay:<tag>.

License

MIT