Moq
The ergonomic Swift Package Manager target for Media over QUIC.
A Swift-native wrapper over the UniFFI-generated bindings: de-prefixed types, AsyncSequence streams, throwing initializers, Sendable handles, and Swift-friendly errors. The raw MoqFFI types it wraps stay out of your way (data types like Frame and Catalog are re-exported under de-prefixed names).
Full API reference: Swift Package Index, which builds and hosts the DocC docs from the /// comments on each tagged release.
Install
.package(url: "https://github.com/moq-dev/moq-swift", from: "0.4.4"),Add Moq to your target's dependencies:
.target(
name: "MyApp",
dependencies: [
.product(name: "Moq", package: "moq-swift"),
],
),The raw MoqFFI bindings and the prebuilt XCFramework are pulled in transitively from moq-dev/moq-swift-ffi; you only depend on moq-swift.
Supported platforms: iOS 15+, iPadOS 15+, macOS 12.3+ (ScreenCaptureKit, which the video backend links, ships in 12.3). The XCFramework ships arm64 slices for iOS devices, the iOS Simulator, and macOS.
Connect
import Moq
let client = Client()
let session = try await client.connect(to: "https://relay.example.com")session.publisher and session.consumer are always populated: by whatever origin you wired via setPublish / setConsume before connecting, or by a fresh auto-created one for any side you left unset. The duplex no-config path (the typical client) shares one origin between both.
For development against a relay with a self-signed certificate:
let client = Client()
client.setTlsVerify(false)
try client.bind("127.0.0.1:0")
let session = try await client.connect(to: "https://localhost:4443")When you're done, signal graceful shutdown to the peer:
session.shutdown() // alias for cancel(code: 0)Inspect an incoming request's logical endpoint before accepting it with the query-free request.path. It is consistent across transports and returns "" for the root or missing path. request.query returns the encoded query and may contain credentials:
let server = Server()
try server.bind("127.0.0.1:4443")
server.generateTls(hostnames: ["localhost"])
_ = try await server.listen()
while let request = try await server.accept() {
if request.path == "/admin" {
try await request.reject(code: 403)
continue
}
let session = try await request.accept()
Task { try? await session.closed() }
}A server can reject the connection on auth grounds: MoqError.Unauthorized (HTTP 401) or MoqError.Forbidden (HTTP 403). These are terminal: retrying without new credentials won't help, so handle them separately from a transient transport failure. Use the isAuth helper to catch both:
do {
let session = try await client.connect(url: "https://relay.example.com")
} catch let error as MoqError where error.isAuth {
// Prompt for credentials; don't reconnect.
}Subscribe
Every consumer is an AsyncSequence, so iterate directly:
let announced = try session.consumer.announced(prefix: "demos/")
for try await announcement in announced {
let catalog = try announcement.broadcast.subscribeCatalog()
for try await update in catalog {
print("catalog: \(update)")
}
}Raw track subscribers can query the publisher's track properties and change their own delivery preferences without resubscribing:
let track = try await announcement.broadcast.subscribeTrack(
name: "events",
subscription: Subscription(priority: 10))
let info = try track.info()
track.update(subscription: 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.
Publish
let broadcast = try session.publisher.createBroadcast(path: "my-stream")
let audio = try broadcast.publishMedia(format: "opus", initData: opusInitBytes)
try audio.writeFrame(payload, timestampUs: 0)
try audio.writeFrame(payload, timestampUs: 20_000)
try audio.finish()
try broadcast.finish()Video publishers can pass video: VideoHint(...) to seed catalog fields before the stream reveals them. Use publishMedia(on:format:initData:video:) to accept a media track obtained from BroadcastDynamic.
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:
try broadcast.setVideoProperties(
VideoProperties(
display: Dimensions(width: 1080, height: 1920),
rotation: 90,
flip: false
)
)For sparse or replayed raw tracks, use track.createGroup(sequence:). track.finish(at:) declares the exclusive end while still permitting lower groups, and group.abort(errorCode:) terminates a group with an application error.
Fetching raw groups
Fetch retrieves one group by track name and group sequence without keeping a live subscription:
let group = try await consumer.fetchGroup(
name: "events",
sequence: 42,
options: FetchGroupOptions(priority: 10)
)
for try await frame in group {
print(frame.timestampUs, frame.payload)
}A retained group resolves immediately. To serve a group that is not retained, keep a dynamic handler alive on its producer:
let dynamic = try track.dynamic()
for try await request in dynamic {
let group = try request.accept()
try group.writeFrame(loadArchivedFrame(request.sequence), timestampUs: request.sequence * 20_000)
try group.finish()
}Call request.abort(errorCode:) 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.
Fetching media groups
fetchGroup hands back raw payloads. fetchMediaGroup decodes the same group through the rendition's advertised container, so you get timestamped frames without opening a live subscription:
let catalog = try await consumer.subscribeCatalog().next()!
let (name, audio) = catalog.audio.first!
let group = try await consumer.fetchMediaGroup(
name: name,
sequence: 42,
container: audio.container
)
for try await frame in group {
print(frame.timestampUs, frame.payload.count)
}MediaGroupConsumer is an AsyncSequence, and cancels the native read when iteration ends. A fetched media group is finite: it completes 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.
On-demand raw tracks
Use a dynamic broadcast when subscribers should be able to request raw tracks that are not published yet:
let broadcast = try session.publisher.createBroadcast(path: "events")
let dynamic = try broadcast.dynamic()
for try await request in dynamic {
if try request.name == "alerts" {
let track = try request.accept()
try track.writeFrame(Data("ready".utf8), timestampUs: 20_000)
try track.finish()
} else {
try request.abort(errorCode: 404)
}
}Each request arrives as a TrackRequest; call accept(info:) to turn it into a TrackProducer (omit info for defaults), or abort(errorCode:) to reject the subscriber. Use writeFrame(_:timestampUs:) with a presentation timestamp in microseconds. Raw tracks default to a microsecond timescale. Raw consumers receive Frame values (payload plus timestamp) from readFrame() and group iteration; media subscriptions yield MediaFrame, which adds the codec-derived keyframe flag.
Raw datagrams
Raw tracks can send a single best-effort payload without opening a group stream:
let sequence = try track.appendDatagram(Data("meter update".utf8), timestampUs: 42_000)
let datagram = try await consumer.recvDatagram()
for try await datagram in consumer.datagrams {
print(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.
JSON tracks
For JSON payloads, publish and subscribe with the framing handled for you. Values are your own Codable types, encoded and decoded at the boundary with JSONEncoder / JSONDecoder. You opt into one of two distinct modes, one method per mode:
- Snapshot (lossy): one value updated over time; a subscriber only sees the latest. Ideal for status documents and metadata. A late joiner catches up to the newest value in one step.
- Stream (lossless): an ordered append-log where every record is preserved. Ideal for event logs and timelines.
struct Status: Codable { var state: String; var viewers: Int }
// Snapshot: each update supersedes the last.
let status = try broadcast.publishJsonSnapshot(name: "status", of: Status.self, compression: true)
try status.update(Status(state: "live", viewers: 42))
try status.update(Status(state: "live", viewers: 43))
let consumer = try broadcast.consume()
for try await value in try await consumer.subscribeJsonSnapshot(name: "status", as: Status.self, compression: true) {
print(value.viewers)
}
// Stream: every record is delivered in order.
struct Event: Codable { var event: String }
let events = try broadcast.publishJsonStream(name: "events", of: Event.self)
try events.append(Event(event: "started"))
for try await record in try await consumer.subscribeJsonStream(name: "events", as: Event.self) {
print(record.event)
}compression must match on the producer and subscriber. Snapshot mode also takes deltaRatio (0 disables merge-patch deltas, so every change is a fresh snapshot). Advertise the track with a catalog section (setCatalogSection) if subscribers should discover it.
On-demand broadcasts
Use a dynamic origin when consumers should be able to request whole broadcasts that are not announced:
let origin = OriginProducer(cacheCapacityBytes: 256 * 1024 * 1024)
let dynamic = origin.dynamic()
for try await request in dynamic {
if try request.path == "events" {
let broadcast = try BroadcastProducer()
let track = try broadcast.publishTrack(name: "status")
try request.accept(broadcast: broadcast)
try track.writeFrame(Data("ready".utf8), timestampUs: 0)
} else {
try request.abort(errorCode: 404)
}
}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(errorCode:) to fail the requester.
Raw media
publishMedia above 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:
let video = try broadcast.publishVideo(
input: VideoEncoderInput(format: .rgba, width: 1280, height: 720, framerate: 30),
output: VideoEncoderOutput(codec: .h264, track: "camera", bitrate: nil, gop: nil, kind: .auto)
)
try video.write(VideoFrame(timestampUs: ptsUs, data: rgba))
try video.finish()kind: .auto prefers a hardware encoder and falls back to software; .software, .hardware, and .named(name: "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. try await video.used() and try await video.unused() 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 try audio.name; try await audio.used() and try await audio.unused() monitor subscriber demand so capture and encoding can stay idle when nobody is listening. Call try audio.resetEpoch() before the first frame after resuming so its timestamp preserves the idle gap.
Cancellation
All async sequences cooperate with structured concurrency. Cancelling the surrounding Task propagates to the underlying cancel() on the consumer:
let task = Task {
for try await frame in mediaConsumer {
process(frame)
}
}
// Later:
task.cancel() // releases native resourcesA note on enum casing
MoqError keeps Rust's PascalCase variants, each carrying message: String (e.g. MoqError.Closed(message: "...")); use error.isShutdown to fold the graceful Cancelled / Closed cases. Plain enums round-trip to lowerCamelCase (AudioFormat.s16, AudioCodec.opus).
Local development
To run the test suite, build a host-only XCFramework first:
just swift checkThis runs swift/scripts/check.sh, which builds moq-ffi for the host arch, regenerates the UniFFI Swift bindings, drops a single-slice MoqFFI.xcframework into swift/, and runs swift test against the monolithic local-dev Package.swift. Requires macOS with xcodebuild.
See also
- API reference: Swift Package Index (DocC)
- Source: swift/Sources/Moq
- Mirror repos: moq-dev/moq-swift (wrapper), moq-dev/moq-swift-ffi (raw bindings)
- The Rust crate this wraps: moq-net