A Go implementation of the Media over QUIC IETF drafts: a transport-agnostic session library, a single-instance reference relay, media packaging libraries, and demo publisher/subscriber CLIs.
- Media over QUIC Transport (MoQT) —
draft-ietf-moq-transport-18 - Low Overhead Media Container (LOC) —
draft-ietf-moq-loc-02 - MoQ Streaming Format (MSF) —
draft-ietf-moq-msf-01
This is library + reference-relay code, not a media player. Payloads are opaque to every layer — applications plug their own codec stack in at the LOC boundary.
Status: tracks moving IETF drafts. The wire format follows the draft versions above and the API is pre-1.0 — both change as the specs evolve.
go get github.com/floatdrop/moq-goRequires Go 1.26 or newer.
Run the stack locally, each command in its own terminal:
go run ./cmd/relay # ephemeral self-signed cert on :4433
go run ./cmd/msfdemo publish # MSF catalog + LOC video frames
go run ./cmd/msfdemo subscribe # discovers the video track from the catalogFor the simpler raw-MOQT case (no LOC/MSF), swap msfdemo for clock.
The mental model:
- A
Sessionis one MOQT connection after the SETUP handshake. You get one fromsession.Clientorsession.Serverover a transportConn. - A publisher opens a
PUBLISHrequest stream, then pushes objects on subgroup uni-streams. - A subscriber opens a
SUBSCRIBErequest stream, then reads objects viaSession.AcceptDataStream. - A track is named by a
(Namespace, Name)pair; a per-session Track Alias is the compact integer that data streams carry.
A minimal publisher looks like this:
sess, err := session.Client(ctx, quicconn.New(qconn),
session.WithImplementation("my-app/0.1"))
if err != nil {
return err
}
defer sess.Close(moqt.SessionNoError, "bye")
pub, err := sess.Publish(ctx, &message.Publish{
Namespace: wire.Namespace("moq-example"),
Name: []byte("clock"),
})
if err != nil {
return err
}
defer pub.Close()
// Publish assigned the Track Alias; the returned Publication carries it, so
// pub.OpenSubgroup fills it in for you. To manage aliases yourself, set
// Publish's TrackAlias (via sess.AllocOutboundTrackAlias) and use
// sess.OpenSubgroup directly.
sg, _ := pub.OpenSubgroup(message.SubgroupHeader{
SubgroupIDMode: message.SubgroupIDImplicitZero,
GroupID: 0,
})
// WriteObjectAt takes absolute Object IDs and computes the §11.4.2 delta
// encoding for you; WriteObject is the lower-level form that takes the delta.
_ = sg.WriteObjectAt(0, &message.SubgroupObject{Payload: []byte("hello")})
_ = sg.Close()A subscriber reads objects off inbound data streams via Session.AcceptDataStream,
type-switching the result to *IncomingSubgroupStream / *IncomingFetchStream.
When you subscribe to several tracks on one session, session.Demux removes the
hand-rolled accept loop: register a handler per track by its Track Alias (and per
FETCH by Request ID), then call Demux.Run.
// sess here is the subscriber's session (from session.Client/Server).
sub, err := sess.Subscribe(ctx, &message.Subscribe{
Namespace: wire.Namespace("moq-example"),
Name: []byte("clock"),
Parameters: message.Parameters{message.LargestObjectFilter()},
})
if err != nil {
return err
}
defer sub.Close()
demux := session.NewDemux()
demux.HandleTrack(sub.TrackAlias(), func(s *session.IncomingSubgroupStream) {
for {
obj, err := s.ReadDecoded() // absolute IDs; deltas resolved for you
if err != nil {
return // io.EOF on clean FIN
}
_ = obj
}
})
go demux.Run(ctx, sess) // HandleTrack is safe to call after Run startsWorked, compile-checked examples for each part of the API live as Go example functions — browse them on pkg.go.dev or read the source, grouped here by the file they live in:
| Topic | Example function(s) |
|---|---|
| Open a session | ExampleClient |
| Publish a track | ExampleSession_Publish |
| Subscribe to a track | ExampleSession_Subscribe |
| Route many tracks' data streams | ExampleDemux |
| Joining / standalone FETCH | ExampleSession_Fetch, ExampleSession_Fetch_standalone, ExampleIncomingFetchStream |
| Update a live request | ExampleSession_UpdateRequest |
| End a publication | Example_endingAPublication |
| Stream exhaustion (PUBLISH_BLOCKED) | ExampleSession_OpenPublish, ExampleSession_ReadPublishBlocked |
| Announce / discover namespaces | ExampleSession_PublishNamespace, ExampleSession_SubscribeNamespace |
| Accept requests (server side) | ExampleSession_AcceptRequest |
| Graceful shutdown (GOAWAY) | ExampleSession_SendGoaway, ExampleSession_OnGoaway |
| Topic | Example function(s) |
|---|---|
| Run / authorize the relay | ExampleNew, ExampleNew_authorizer |
| Topic | Example function(s) |
|---|---|
| LOC media packaging | ExampleObject_Encode |
| Topic | Example function(s) |
|---|---|
| MSF catalogs (build / parse / delta) | ExampleBeginBroadcast, Example_subscribeCatalog, ExampleApply |
The two demo commands — cmd/clock and
cmd/msfdemo — are complete, runnable versions of these patterns
end to end; each has its own README with sequence diagrams.
pkg/moqt/ MOQT protocol implementation
├── wire/ Wire-format primitives (varint, KV pairs, namespaces, framing)
├── message/ Typed control- and request-stream messages
├── track/ Full track name + canonical map keys
├── session/ SETUP handshake, control multiplexing, GOAWAY, alias mgmt
│ ├── quicconn/ Native-QUIC Conn adapter (quic-go)
│ ├── wtconn/ WebTransport Conn adapter (webtransport-go)
│ └── sessiontest/ In-process pipe-backed Conn for tests
├── loc/ Low-Overhead Container per draft-ietf-moq-loc-02
├── msf/ MOQT Streaming Format per draft-ietf-moq-msf-01
└── errors.go Session / request / publish-done / stream-reset codes
pkg/relay/ Single-instance MOQT relay
├── cache/ Per-track object cache
└── discovery/ Cross-instance discovery interface + in-memory impl
cmd/
├── relay/ MOQT relay binary
├── interop-client/ moq-interop-runner test client (drives the session library)
├── clock/ Wall-clock publish/subscribe demo (raw MOQT)
└── msfdemo/ MSF catalog + LOC video frame demo
apps/
└── tlmst/ Wails3 desktop app (separate Go module, isolated deps)
wire— byte-level codec: MoQT leading-ones varints (§1.4.1, distinct from QUIC's RFC 9000 varints), length-prefixed bytes, KV pairs with delta-encoded types (§1.4.3), track namespaces (§2.4.1), reason phrases, and both an in-memoryReaderand a streamingDecoderover the same control-frame interface.message— typed control, request-stream, and data-stream messages with parameter negotiation: SETUP, GOAWAY, SUBSCRIBE, PUBLISH (+ DONE/BLOCKED), FETCH (standalone, relative/absolute joining), TRACK_STATUS, REQUEST_UPDATE, the namespace messages, the §11 object framing (subgroup, fetch, datagram) with absence markers, subscription filters, GREASE handling, and aValidatehook the decoder runs on parse to reject structurally-malformed messages (FETCH End < Start, REQUEST_ERROR redirect consistency, object status/flags, …).session— the SETUP handshake with version negotiation, control multiplexing and request-ID allocation, Track-Alias management with §3.5 collision detection, the request openers (Publish/Subscribe/Fetch/…) and theAcceptRequestresponder, typed inbound data streams that resolve §11.4.2/§11.4.4 deltas to absolute IDs, GOAWAY, the §10.20 token cache, and pluggable transport via theConninterface (quicconn+wtconnadapters).loc—Object.Encode/Decodeproducing the bytes that drop into aSubgroupObject: typed Timestamp/Timescale/VideoConfig/VideoFrameMarking/ AudioLevel properties withExtraspassthrough for unknown IDs, an RFC 6464 audio-level codec, and AVC/HEVC NAL framing detection.msf—Catalog/TrackJSON (independent and delta catalogs, withApplyreplaying delta operations in document order), group-ID sequencing, the Media and Event Timeline record formats, theBeginBroadcast/EndBroadcast*workflow helpers, andCatalog.Validateenforcing the §5.1/ §5.2 invariants.relay— accepts publisher and subscriber sessions, routes objects through a track registry with per-subscription live fanout under a §8 latency-window slow-reader policy, merges multiple upstream publishers per track (§9.5) into one outbound subgroup stream per subscriber with §2.1 {Group, Object} deduplication and survivor-continues failover, serves FETCHes from a per-track object cache and stitches the evicted part of a range from an upstream FETCH, issues on-demand upstream SUBSCRIBEs to every matching publisher (a local publisher and/or, via aDiscoveryStoreFindNamespacelookup + a pluggableDialer, each advertising relay instance), reflects namespaces other relays advertise to local subscribers by consumingWatchNamespaces, forwards namespace interest, gates each request through anAuthorizerhook, emits telemetry through aMetricshook, and drains sessions with GOAWAY.- CLIs —
cmd/relay(with cert flags + self-signed fallback),cmd/clock(raw subgroup demo), andcmd/msfdemo(the LOC + MSF stack end to end).
This is a single-instance reference relay, though cross-relay routing works
when wired up: set Config.Discovery + Config.Dialer and the relay follows a
FindNamespace lookup to dial and subscribe upstream on another instance (and
reflects remote namespaces to local subscribers via WatchNamespaces). What
remains out of scope: multi-hop loop detection (the only guard is skipping
the relay's own RelayAddr), an upstream connection-health / redial policy
beyond dial-on-demand, production DiscoveryStore backends (only the in-process
MemoryStore ships), GOAWAY cascading, and a Dialer for cmd/relay
itself (the binary stays single-instance; cross-relay is library-level). Known
gaps in the current code, roughly ordered by how
load-bearing they are:
- Late publisher pickup (§9.5) — multiple publishers per track are merged and deduplicated, and an on-demand SUBSCRIBE subscribes to every publisher matching at that moment. A publisher (or remote relay) that begins advertising after a track's upstream set is established is not retroactively pulled in until that set drains and a fresh SUBSCRIBE re-establishes it; publishers that PUBLISH proactively are always merged regardless.
- Subscriber-priority scheduling (§10.2.7) — fully plumbed but not yet
enforced on the wire.
SUBSCRIBER_PRIORITYis parsed and stored, the §7.2 four-rule composite key is computed (EffectiveStreamPriority) and pushed through thesession.PrioritizedSendStreaminterface on every stream open/reopen (propagation is end-to-end test-covered). The missing piece is a transport adapter that honors the knob: quic-go and webtransport-go expose no per-stream priority API today, so the bundled adapters silently absorb it and quic-go round-robins instead. Lights up with a one-line adapter change once quic-go#437 lands. A REQUEST_UPDATE that changes the priority mid-stream applies to subsequently opened subgroup streams, not in-flight ones. - LOC encryption / SecureObjects (LOC §3) and Private Properties —
intentionally out of scope pending a chosen SecureObjects revision. Property
IDs are draft-tentative (
PropAudioLevel = 0x0Adeviates from the draft's unassigned suggested6, which collides with the registeredPropTimestamp(0x06); pending IANA assignment). - MSF — no timeline GZIP compression, content protection (§4.3), token authorization, or logs/analytics (each is a TODO or unspecified in the draft). There is no built-in ABR helper: the library surfaces every catalog field a selector needs (AltGroup, Width/Height, Bitrate, RenderGroup, Depends, TemporalID, SpatialID), but variant-selection policy is the application's job.
go build ./...
go test ./... # full suite — hermetic, no fixtures or network
go test -race ./pkg/moqt/session/... # race detector for goroutine/stream code
golangci-lint run # lint + format check (.golangci.yml)go test ./... from the root does not include apps/tlmst (a separate module
with CGO/WebKit deps). For the benchmark suite and the benchstat
regression-comparison workflow, see
benchmarks/README.md.
Interop is tested in both directions against independent implementations:
- Relay direction —
make interopruns a third-party MoQT test client (from the moq-interop-runner) against our relay over both transports;make interop-matrixruns several clients and prints a pass/skip/fail matrix. - Client direction —
make interop-clientruns our own test client (cmd/interop-client) against a relay (loopback by default; overrideCLIENT_RELAY_IMAGE/CLIENT_RELAY_URLfor a third-party relay).
See cmd/relay/README.md for the targets and options;
current results are tracked in STATUS.md.
CI runs on every push and pull request
(.github/workflows/ci.yml): go build ./...,
go test ./..., go test -race ./..., golangci-lint run, a govulncheck
scan, and the interop suite (make interop and make interop-client). The
interop run is not redundant with go test: the unit tests round-trip through
our own codec, so a wire-encoding regression (e.g. emitting QUIC varints instead
of the §1.4.1 leading-ones encoding) passes every unit test yet breaks interop —
only a run against an independent implementation catches it. The advisory
make interop-matrix is not gated, as it has known cross-implementation
divergences (see STATUS.md).
Licensed under either:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)