moq
The ergonomic Go module for Media over QUIC. This is the package most callers want.
It wraps the raw moq-ffi bindings in idiomatic Go: context.Context cancellation, Go error returns (with an IsShutdown helper for graceful Cancelled/Closed), and Go 1.23 range-over-func iterators (iter.Seq2) for live streams. The record and enum types are re-exported without the Moq prefix, so most programs never import the ffi package directly.
Full API reference: pkg.go.dev/github.com/moq-dev/moq-go, generated automatically from the godoc.
Install
go get github.com/moq-dev/moq-go@latestimport "github.com/moq-dev/moq-go/moq"CGO_ENABLED=1 is required (the default on Unix). The prebuilt libmoq_ffi.a comes transitively from moq-ffi, which the wrapper requires; cgo selects the right archive automatically, so there's no Rust toolchain or shared-library setup.
Go's minimum-version-selection resolves to the maximum moq-ffi across your build graph, and CI re-publishes the wrapper with its require bumped to the newest moq-ffi on every release, so @latest always pulls the latest native core.
Quick start
ctx := context.Background()
client, err := moq.Dial(ctx, "https://relay.example.com")
if err != nil {
log.Fatal(err)
}
defer client.Close()
announced, err := client.Announced("demos/")
if err != nil {
log.Fatal(err)
}
for ann, err := range announced.All(ctx) {
if err != nil {
if moq.IsShutdown(err) {
break
}
log.Fatal(err)
}
fmt.Println("got broadcast", ann.Path())
catalog, err := ann.Broadcast().Catalog(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Printf("catalog: %+v\n", catalog)
}TLS and stats
Use certificate roots or fingerprints when a client needs to trust a private or self-signed endpoint without disabling verification:
client, err := moq.Dial(ctx, "https://relay.example.com",
moq.WithTLSRoots("/etc/ssl/custom-ca.pem"),
moq.WithTLSSystemRoots(true),
moq.WithTLSFingerprints("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
stats := client.Session().Stats()
fmt.Printf("rtt: %v\n", stats.RttUs)Stats() returns a snapshot. Individual fields are nil when the transport does not report that metric yet.
Accept connections
Inspect an incoming request's logical endpoint before accepting it with the query-free Path(). It is consistent across transports and returns "" for the root or missing path. Query() returns the encoded query and may contain credentials:
server, err := moq.Listen(ctx, "127.0.0.1:4443", moq.WithTLSGenerate("localhost"))
if err != nil {
log.Fatal(err)
}
defer server.Close()
for request, err := range server.Requests(ctx) {
if err != nil {
log.Fatal(err)
}
if request.Path() == "/admin" {
_ = request.Reject(ctx, 403)
continue
}
session, err := request.Accept(ctx)
if err != nil {
log.Fatal(err)
}
go func() { _ = session.Closed(ctx) }()
}Dynamic tracks
BroadcastProducer.Dynamic() lets a publisher accept tracks that subscribers request before they exist:
broadcast, err := moq.NewBroadcastProducer()
if err != nil {
log.Fatal(err)
}
defer broadcast.Finish()
dynamic, err := broadcast.Dynamic()
if err != nil {
log.Fatal(err)
}
defer dynamic.Cancel()
go func() {
request, err := dynamic.RequestedTrack(ctx)
if err != nil {
return
}
track, err := request.Accept(nil)
if err != nil {
return
}
_ = track.WriteFrame(moq.Frame{Payload: []byte("ready")})
}()For media tracks, let the importer accept the request:
request, err := dynamic.RequestedTrack(ctx)
if err != nil {
log.Fatal(err)
}
media, err := broadcast.PublishMediaOnTrack(request, "opus", opusInit)
if err != nil {
log.Fatal(err)
}
_ = media.WriteFrame(moq.Frame{Payload: opusFrame, TimestampUs: 20_000})Video catalog fields that are known before the first keyframe can be supplied with WithVideoHint:
media, err := broadcast.PublishMedia("avc3", nil, moq.WithVideoHint(moq.VideoHint{
Coded: &moq.Dimensions{Width: 1920, Height: 1080},
}))Each catalog Video has a Stalled boolean. A true value recommends temporarily avoiding that rendition, but the track remains directly usable. Existing catalogs default it to false.
Properties that apply to every video rendition are updated together. Nil fields clear the corresponding catalog property, and rotation is normalized to the nearest clockwise quarter turn:
rotation := 90.0
flip := false
err := broadcast.SetVideoProperties(moq.VideoProperties{
Display: &moq.Dimensions{Width: 1080, Height: 1920},
Rotation: &rotation,
Flip: &flip,
})Error handling
A server can reject the connection on auth grounds: ErrMoqErrorUnauthorized (HTTP 401) or ErrMoqErrorForbidden (HTTP 403). These are terminal: retrying without new credentials won't help, so handle them separately from a transient transport failure. The moq.IsAuthError helper catches both:
session, err := client.Connect("https://relay.example.com")
if moq.IsAuthError(err) {
// Prompt for credentials; don't reconnect.
}Publishing lifetime
A broadcast stays live only while you hold its BroadcastProducer. Once the producer is garbage-collected the path unannounces and subscribers get a reset mid-stream. This bites when the producer goes out of scope while a background goroutine is still writing to its tracks.
Keep a reference for as long as you are publishing, then close it explicitly when done:
broadcast, err := origin.CreateBroadcast("my-broadcast.hang")
if err != nil {
// handle error
}
mediaProducer, err := broadcast.PublishMedia("aac", asc)
if err != nil {
// handle error
}
// Keep `broadcast` reachable while producing (e.g. store it on a struct the
// publishing goroutine owns). Don't let it fall out of scope here.
produceAudio(mediaProducer)
// Finish() closes the broadcast cleanly and unpublishes it immediately,
// so subscribers see a normal end.
broadcast.Finish()If a producer is collected without Finish(), the underlying library logs a warning (broadcast::Producer dropped without close()) to help you spot the leak.
Raw media
PublishMedia takes frames you already encoded. To hand over raw pixels or PCM instead and let the codec run inside the bindings, use PublishVideo / PublishAudio. Pixel format, resolution, and framerate are fixed at publish time, so each frame carries only its pixels and a timestamp:
track := "camera"
video, err := broadcast.PublishVideo(
moq.VideoEncoderInput{
Format: moq.VideoPixelFormatRgba,
Width: 1280,
Height: 720,
Framerate: 30,
},
moq.VideoEncoderOutput{
Codec: moq.VideoCodecH264,
Track: &track,
Kind: moq.AutoEncoder(),
},
)
if err != nil {
// handle error
}
err = video.Write(moq.VideoFrame{TimestampUs: ptsUs, Data: rgba})
// ...
video.Finish()AutoEncoder() prefers a hardware encoder and falls back to software; SoftwareEncoder(), HardwareEncoder(), and NamedEncoder("videotoolbox") pin the choice. The bindings compile VideoToolbox (macOS), Media Foundation (Windows), NVENC (Linux, NVIDIA), and openh264 (software, everywhere). A hardware encoder that is compiled in but can't open, because there is no GPU or because its driver libraries aren't on the loader path, logs a warning naming the reason and falls through to software, so a host that quietly encodes on the CPU says so. SetBitrate retunes the live encoder without forcing a keyframe, cheap enough to drive from a congestion controller.
Set Track to choose the track name; omit it to derive one from the codec (.avc3 / .hev1). The catalog rendition is published immediately so subscribers can discover it before the first frame exists. Used(ctx) and Unused(ctx) monitor subscriber demand. Call Cut() before the first frame after an idle gap so the resumed stream starts with a keyframe in a new group.
Raw audio takes an explicit track name at publish time. Read it back through audio.Name(); audio.Used(ctx) and audio.Unused(ctx) monitor subscriber demand so capture and encoding can stay idle when nobody is listening. Call audio.ResetEpoch() before the first frame after resuming so its timestamp preserves the idle gap.
Raw Track Controls
Raw track subscribers can query the publisher's track properties and change their own delivery preferences without resubscribing:
subscription := moq.Subscription{Priority: 10, Ordered: true}
track, err := broadcast.SubscribeTrack("events", &subscription)
if err != nil {
log.Fatal(err)
}
info, err := track.Info()
if err != nil {
log.Fatal(err)
}
if info.Timescale != nil {
fmt.Println("timescale", *info.Timescale)
}
track.Update(moq.Subscription{Priority: 20, Ordered: false})Ordered controls prioritization only. When true, groups are prioritized in sequence order. Groups may always arrive out-of-order (or not at all) over the network.
For sparse or replayed tracks, use CreateGroup(sequence). FinishAt(finalSequence) declares the exclusive end while still permitting lower groups, and Abort(errorCode) terminates a track or group with an application error.
JSON tracks
Use PublishJSONSnapshot / SubscribeJSONSnapshot for lossy latest state and PublishJSONStream / SubscribeJSONStream for a lossless append log. Producers accept values supported by encoding/json; consumers return json.RawMessage.
Fetching raw groups
Fetch retrieves one group by track name and group sequence without keeping a live subscription:
group, err := consumer.FetchGroup("events", 42, &moq.FetchGroupOptions{Priority: 10})
if err != nil {
log.Fatal(err)
}
for frame, err := range group.Frames(ctx) {
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s\n", frame)
}A retained group resolves immediately. To serve a group that is not retained, keep a dynamic handler alive on its producer:
dynamic, err := track.Dynamic()
if err != nil {
log.Fatal(err)
}
request, err := dynamic.RequestedGroup(ctx)
if err != nil {
log.Fatal(err)
}
producer, err := request.Accept()
if err != nil {
log.Fatal(err)
}
_ = producer.WriteFrame(moq.Frame{Payload: loadArchivedFrame(request.Sequence()), TimestampUs: request.Sequence() * 20_000})
_ = producer.Finish()Call request.Abort(code) when the requested group cannot be produced. Fetch is currently a single-group operation and is supported by the moq-lite 05+ FETCH wire path.
To serve requests in a loop, range over dynamic.Requests(ctx) instead, the same shape BroadcastDynamic and OriginDynamic use:
for request, err := range dynamic.Requests(ctx) {
if err != nil {
if moq.IsShutdown(err) {
break
}
log.Fatal(err)
}
producer, err := request.Accept()
if err != nil {
log.Fatal(err)
}
_ = producer.WriteFrame(loadArchivedFrame(request.Sequence()), request.Sequence()*20_000)
_ = producer.Finish()
}Fetching media groups
FetchGroup hands back raw payloads. FetchMediaGroup decodes the same group through the rendition's container, so you get timestamped frames without opening a live subscription:
catalog, err := consumer.Catalog(ctx)
if err != nil {
log.Fatal(err)
}
group, err := consumer.FetchMediaGroup("audio0", 42, catalog.Audio["audio0"].Container, nil)
if err != nil {
log.Fatal(err)
}
for frame, err := range group.Frames(ctx) {
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d: %d bytes\n", frame.TimestampUs, len(frame.Payload))
}A fetched media group is finite: it ends after the group's last decoded frame, unlike the live SubscribeMedia stream. Latency-based group skipping does not apply, so you always get every frame in the group.
Raw track timestamps
Raw tracks carry arbitrary byte payloads. WriteFrame takes a Frame, whose TimestampUs is a caller-supplied presentation timestamp in microseconds, and raw tracks default to a microsecond timescale. ReadFrame returns the timestamped raw frame:
track, _ := broadcast.PublishTrack("events", nil)
consumer, _ := track.Consume(nil)
_ = track.WriteFrame(moq.Frame{Payload: []byte("ready"), TimestampUs: 20_000})
frame, err := consumer.ReadFrame(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(frame.Payload), frame.TimestampUs)Raw datagrams
Raw tracks can send a single best-effort payload without opening a group stream:
sequence, err := track.AppendDatagram(moq.Frame{Payload: []byte("meter update"), TimestampUs: 42_000})
if err != nil {
return err
}
datagram, err := consumer.RecvDatagram(ctx)
if err != nil {
return err
}
for datagram, err := range consumer.Datagrams(ctx) {
if err != nil {
return err
}
fmt.Println(datagram.Sequence, datagram.TimestampUs)
}Datagrams are delivered as Datagram{Sequence, TimestampUs, Payload}. Payloads are capped at 1200 bytes. Delivery requires a datagram-capable transport and lite-05 or newer moq-lite; IETF moq-transport, pre-lite-05, WebSocket, and TCP paths do not deliver them, and there is no stream fallback.
On-demand broadcasts
Use a dynamic origin when consumers should be able to request whole broadcasts that are not announced:
capacity := uint64(256 * 1024 * 1024)
origin := moq.NewOriginProducerWithOptions(moq.OriginOptions{
CacheCapacityBytes: &capacity,
})
dynamic := origin.Dynamic()
defer dynamic.Cancel()
for request, err := range dynamic.Requests(ctx) {
if err != nil {
if moq.IsShutdown(err) {
break
}
log.Fatal(err)
}
path, err := request.Path()
if err != nil {
log.Fatal(err)
}
if path != "events" {
if err := request.Abort(404); err != nil {
log.Fatal(err)
}
continue
}
broadcast, err := moq.NewBroadcastProducer()
if err != nil {
log.Fatal(err)
}
track, err := broadcast.PublishTrack("status", nil)
if err != nil {
log.Fatal(err)
}
if err := request.Accept(broadcast); err != nil {
log.Fatal(err)
}
if err := track.WriteFrame(moq.Frame{Payload: []byte("ready")}); err != nil {
log.Fatal(err)
}
}The served broadcast is not announced. It only resolves consumers that call RequestBroadcast(path). Each request arrives as a BroadcastRequest; call Accept(broadcast) to serve it, or Abort(code) to fail the requester.
Local development
The in-tree go/wrapper/ directory is the source skeleton; CI publishes it to the moq-dev/moq-go mirror. To exercise it locally:
just go checkThis runs go/scripts/check.sh, which builds moq-ffi for the host arch, regenerates the bindings with uniffi-bindgen-go, stages both the ffi and wrapper modules into dist/ (wiring the wrapper to the freshly-built ffi via a local replace), and runs go vet/go build/go test. Requires cargo, go, and uniffi-bindgen-go on the path; see moq-ffi for the install.
The committed go/wrapper/go.mod carries a require github.com/moq-dev/moq-go-ffi v0.0.0 placeholder; the local replace and CI's release-time rewrite supply the real version. Don't "fix" it by hand.
See also
- API reference: pkg.go.dev/github.com/moq-dev/moq-go
- Source: go/wrapper
- Mirror repo: moq-dev/moq-go
- Raw bindings it builds on: moq-ffi
- The Rust crates this wraps: moq-net + moq-mux