A Go implementation of the Media over QUIC IETF drafts: a transport-agnostic session library, a reference relay that runs standalone or as several instances routing across each other, media packaging libraries, and demo publisher/subscriber CLIs.
- Media over QUIC Transport (MoQT) —
draft-ietf-moq-transport-22 - Low Overhead Media Container (LOC) —
draft-ietf-moq-loc-04 - MoQ Streaming Format (MSF) —
draft-ietf-moq-msf-01 - CMAF-compliant MSF (CMSF) —
draft-ietf-moq-cmsf-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.27 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/video -in clip.mp4 publish # MSF catalog + CMAF-packaged video
go run ./cmd/video -out /tmp/recv.mp4 subscribe # reassembles it and reports deliveryFor the simpler raw-MOQT case (no MSF/CMSF), swap video for clock.
cmd/relay is one instance. Several instances routing across each other need a
distributed DiscoveryStore.
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
}
// Done ends the publication gracefully (PUBLISH_DONE, then FIN). pub.Close()
// cancels it instead (§6.4.2.3).
defer pub.Done(moqt.PublishDoneTrackEnded, "")
// 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.3.1 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 startsA relay's own program publishes on Relay.Connect's session what it would
have the relay originate (a figure it computes per subscriber, say), and every
subscriber reaches the track as it reaches a peer's. The session runs over an
in-process connection (session/memconn), served as LegInProcess with every
request authorized, and ends with the relay's Stop:
own, err := r.Connect(ctx) // r is a started *relay.Relay
if err != nil {
return err
}
pub, err := own.Publish(ctx, &message.Publish{
Namespace: wire.Namespace("room"),
Name: []byte("link/p1"),
})
if err != nil {
return err
}
_ = own.SendDatagram(&message.ObjectDatagram{
TrackAlias: pub.TrackAlias(), GroupID: 0, ObjectPayload: []byte("rtt=42"),
})Worked, 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 |
| Route inbound requests (server side) | ExampleRequestMux |
| Joining / standalone FETCH | ExampleSession_Fetch, ExampleSession_Fetch_standalone, ExampleIncomingFetchStream |
| Update a live request | ExampleSession_UpdateRequest |
| Serve a long-lived request stream (updates + follow-ups) | ExampleRequestBroker |
| End a publication | Example_endingAPublication |
| Stream exhaustion (PUBLISH_SKIPPED) | ExampleSession_OpenPublish, ExampleTrackSubscription_ReadPublishSkipped |
| Announce / discover namespaces | ExampleSession_PublishNamespace, ExampleSession_SubscribeNamespace |
Accept requests + reply (Accept* helpers) |
ExampleSession_AcceptRequest |
| Graceful shutdown (GOAWAY) | ExampleSession_SendGoaway, ExampleSession_OnGoaway |
| Topic | Example function(s) |
|---|---|
| Run the relay / graceful shutdown | ExampleNew |
| Authorize requests | ExampleNew_authorizer |
| Relay metrics | ExampleMetrics |
| Topic | Example function(s) |
|---|---|
| LOC media packaging | ExampleObject_Encode |
| Topic | Example function(s) |
|---|---|
| MSF catalogs (build / parse / delta) | ExampleBeginBroadcast, Example_subscribeCatalog, ExampleApply |
| CMSF CMAF packaging + DRM (CMSF) | Example_contentProtection, ExampleSAPRecord |
The two demo commands — cmd/clock and
cmd/video — are complete, runnable versions of these patterns
end to end; each has its own README. cmd/video streams a real media file as a
CMSF broadcast and reports what the transport did to it, which is also the tool
to reach for when a player is showing artefacts and the cause is unclear.
A per-feature breakdown of draft-22 completeness, the full list of what's
implemented per package, and known limitations live in
STATUS.md.
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)For the benchmark suite and the benchstat regression-comparison workflow, see
benchmarks/README.md.
The repo ships a devenv config (devenv.nix) that pins the
Go toolchain (matching go.mod) plus golangci-lint, golines, goimports,
gopls, and dlv — so everyone builds against the same versions. It is
optional: a plain go install works fine. With Nix
installed:
# One-time: install devenv and direnv.
nix profile add nixpkgs#devenv nixpkgs#direnvThen either enter the shell on demand:
devenv shell # drops you into a shell with the full toolchain on PATH
devenv test # sanity-check the toolchain wiring…or let direnv load it automatically on cd (recommended).
Hook direnv into your shell once (see the
direnv setup guide), e.g. for zsh:
echo 'eval "$(direnv hook zsh)"' >> ~/.zshrcthen trust this repo's .envrc once:
direnv allow # run from the repo root; re-run if .envrc changesAfter that the environment activates whenever you enter the directory. Inside
the shell, convenience scripts wrap the canonical commands: build, test,
test-race, lint, bench, and modernize.
This implementation is registered (as moq-go) in the
moq-interop-runner, which
exercises it in both directions against independent draft-18 implementations.
See cmd/relay/README.md for the local make interop
targets.
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. 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 §8.1 leading-ones encoding) passes
every unit test yet breaks interop — only a run against an independent
implementation catches it.
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)