Historical exploration notes from 2026-03-19 while evaluating upstream relay behavior. These notes are useful for background, but they are not the authoritative
moqxAPI or TLS guidance.For the current supported
moqxsurface, secure-by-default TLS behavior, and the preferred localmkcertworkflow, see the top-levelREADME.md.
Tests run on 2026-03-19, using moq-dev/moq at HEAD. Platform: MNT Reform 2020 (aarch64, Rust 1.94.0).
Setup
Build
git clone https://github.com/moq-dev/moq
cd moq
cargo build --release -p moq-relay -p moq-cli -p moq-clock
Start the relay
The repo ships a ready-to-use local config at dev/relay.toml:
./target/release/moq-relay dev/relay.toml
Key settings in dev/relay.toml at the time:
- Listens on
[::]:4443for both QUIC and HTTP/WebSocket - Self-signed TLS certificate generated for
localhost - Anonymous access enabled (
auth.public = "") - Serves
GET /certificate.sha256so clients can pin the cert fingerprint
For current moqx development, treat that generated localhost certificate as
untrusted by default unless you explicitly opt into insecure mode or replace it
with a locally trusted certificate chain.
Experiment 1: Clock pub/sub (moq-clock)
moq-clock is a minimal example app in the repo — publishes current time as a
text track every second. Perfect for protocol validation.
Publisher
./target/release/moq-clock \
--url http://localhost:4443/ \
--broadcast anon/clock-test \
--tls-disable-verify \
publish
Historical upstream experiment note: this used an insecure local-dev flow. Current
moqxguidance is to prefer trusted local certificates and keep verification on by default.
Subscriber (separate terminal)
./target/release/moq-clock \
--url http://localhost:4443/ \
--broadcast anon/clock-test \
--tls-disable-verify \
subscribe
Observed output
Subscriber receives frames ~1s after publisher produces them:
2026-03-19 16:39:03
2026-03-19 16:39:04
2026-03-19 16:39:05
...Log analysis
Both publisher and subscriber connect via QUIC (moq-lite-03):
WARN moq_native::websocket: WebSocket connection failed err=failed to connect WebSocket
INFO moq_native::client: connected version=moq-lite-03The WebSocket fallback always fails locally because QUIC wins the race (200ms delay). The relay serves both on the same port 4443 — QUIC via UDP, WebSocket via TCP.
Broadcast lifecycle in the logs:
INFO moq_clock: waiting for broadcast to be online broadcast=anon/clock-test
INFO moq_clock: broadcast is online, subscribing to track broadcast=anon/clock-test
INFO moq_lite::lite::subscriber: subscribe started id=0 broadcast=anon/clock-test track=seconds
INFO moq_lite::lite::publisher: subscribed started id=0 broadcast=anon/clock-test track=secondsThe subscriber waits for the broadcast to be announced — no polling, pure event-driven.
Experiment 2: Video publish (ffmpeg → moq-cli)
Publish a looping fMP4 file via ffmpeg piped to moq-cli.
The correct ffmpeg movflags for MOQ (from the justfile):
ffmpeg -stream_loop -1 -re -i dev/test.fmp4 \
-c copy \
-f mp4 -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \
- \
| ./target/release/moq-cli \
--log-level info \
publish \
--url http://localhost:4443/ \
--name anon/my-stream \
--websocket-enabled \
--tls-disable-verify \
fmp4
⚠️ The flag
frag_keyframe+empty_moov+default_base_moof(generic fMP4) does NOT work. MOQ requires CMAF fragmentation:cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame.
The subscribe side has been removed from moq-cli and moved to the
GStreamer plugin (hang-gst).
Protocol observations
URL scheme and TLS
http://URL → client fetchesGET /certificate.sha256first (insecure pin), then connects via QUIC using the pinned fingerprinthttps://URL → standard TLS verification against system roots
This section describes the upstream experiment setup at the time. In moqx, the
intentional library posture is now:
- verification on by default
- explicit insecure mode only for local development
- preferred local trusted-cert workflow via
mkcert
Connection negotiation
The client simultaneously attempts:
- QUIC (WebTransport over UDP)
- WebSocket fallback (TCP) — with a 200ms delay by default
Whoever connects first wins. In LAN, QUIC always wins. WebSocket is useful for environments where UDP is blocked.
moq-lite-03 vs moq-transport-14+
- The relay negotiates the version automatically
- moq-lite is a simplified subset
- moq-transport (IETF draft) is supported by Cloudflare CDN and MOQtail
Current moqx integration coverage is more specific than this historical note:
raw QUIC and WebTransport support a broader version set than the relay-backed
WebSocket path, which intentionally tracks the upstream-compatible subset.
Broadcast addressing
- Broadcasts are addressed by path: e.g.
anon/clock-test - The
anon/prefix is the public (unauthenticated) namespace configured indev/relay.toml - A broadcast contains multiple tracks (e.g.
seconds,video,audio) - A subscriber first waits for the broadcast announcement, then subscribes to individual tracks
Public relay test
Historical TODO section. Validate current upstream/public relay behavior before treating any version claims here as present-day
moqxguidance.
Cloudflare hosts a public MoQ relay:
https://draft-14.cloudflare.mediaoverquic.comSupports moq-transport-14+ (not moq-lite). Use
--client-version moq-transport-14to force the right version when testing against it.
TODO: test pub/sub against Cloudflare relay.
For current moqx defaults, we commonly target:
https://ord.abr.moqtail.devA browser demo player is available at:
https://abr.moqtail.dev/demoUse it as a quick external sanity check when relay-based tests/tasks fail, to differentiate local client regressions from relay availability issues.
moq-clock source — key patterns for the Elixir binding
Source: rs/moq-clock/src/clock.rs. The actual subscriber loop:
// Publisher side — Groups are per-minute, Frames are per-second
pub async fn run(mut self) -> anyhow::Result<()> {
loop {
let mut group = self.track.create_group(sequence.into()).unwrap();
// First frame: base timestamp prefix "2026-03-19 16:39:"
group.write_frame(base.clone())?;
// Subsequent frames: just the seconds "05", "06", ...
loop {
group.write_frame(delta)?;
tokio::time::sleep(one_second).await;
if minute_changed() { break; }
}
group.finish()?;
}
}
// Subscriber side — pure async pull
pub async fn run(mut self) -> anyhow::Result<()> {
while let Some(mut group) = self.track.recv_group().await? {
// First frame is the base prefix
let base = group.read_frame().await?.context("empty group")?;
// Subsequent frames are the per-second delta
while let Some(delta) = group.read_frame().await? {
println!("{}{}", String::from_utf8_lossy(&base), String::from_utf8_lossy(&delta));
}
}
Ok(())
}Key observations for moqx
Group = keyframe boundary — a new Group starts a decodable unit. For the clock it's per-minute; for video it's per-GOP. Groups can arrive out-of-order (QUIC streams are independent), but Frames within a group are always in-order.
Pull model —
recv_group()andread_frame()are blocking awaits. No callbacks, no channels — pure Rust async. Maps cleanly to Rustler dirty scheduler threads.Backpressure is QUIC-native — if a subscriber is slow, QUIC flow control kicks in automatically. No application-level backpressure needed at the moq-lite layer.
Elixir mapping:
- Each
Track→ oneGenServer(or supervised Task) holding aResourceArc<TrackConsumer> recv_group()→ dirty NIF thread blocks, sends{:moq_group, group_ref}to the owner PIDread_frame()→ dirty NIF thread blocks, sends{:moq_frame, group_ref, payload}to owner- Group sequence numbers → can be used for latency monitoring (detect skipped groups)
- Each