Swift
The Moq Swift package: de-prefixed types, AsyncSequence on every consumer, Sendable handles, and Task cancellation that reaches the native side. It depends on MoqFFI, which ships a prebuilt XCFramework with arm64 slices for iOS 16+, the iOS Simulator, and macOS 12.3+.
dependencies: [
.package(url: "https://github.com/moq-dev/moq-swift", from: "<version>"), // latest: see the badge above
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "Moq", package: "moq-swift")]),
]import Moq
// Subscribe. The sequence is live, so run it in its own Task.
let client = Client()
let session = try await client.connect(to: "https://relay.example.com")
for try await event in try session.consume.announced(prefix: "live/", filter: "*/camera") {
guard case .start(let announcement) = event else { continue } // .update, .end, or .live
// Prefixes stay origin-relative; captures reports what each wildcard matched.
print(announcement.captures ?? [])
let broadcast = try await session.consume.requestBroadcast(path: announcement.prefix)
for try await catalog in try await broadcast.subscribeCatalog() {
print(catalog)
}
}// Publish encoded frames, or raw pixels with the codec inside the binding (VideoToolbox).
// opusInit, packet, pts, and rgba come from your encoder or capture source.
let broadcast = try session.publish.createBroadcast(path: "my-stream.hang")
let audio = try broadcast.publishAudio(format: .opus, initData: opusInit)
try audio.writeFrame(packet, timestampUs: 20_000)
let video = try broadcast.encodeVideo(
input: VideoEncoderInput(format: .rgba, width: 1280, height: 720, framerate: 30),
output: VideoEncoderOutput(codec: .h264, track: "camera", kind: .auto)
)
try video.write(VideoFrame(timestampUs: pts, data: rgba))
try broadcast.announce()
try audio.finish()
try video.finish()
try broadcast.close()
try await session.shutdown()For already-encoded live output, call audio.flush(timestampUs:) after writeFrame with the same broadcast-clock PTS. It measures catalog jitter at the transport handoff. File, pipe, and network imports should omit flush; built-in encoders observe their own output.
Call audio.discontinuity() when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. On a track from publishVideo, resume with a keyframe: a delta frame before it fails.
The three advertising operations: session.publish.createBroadcast(path:) returns an unannounced producer, invisible to everyone; broadcast.announce(route:) / broadcast.unannounce() own that exact-path advertisement, and broadcast.close() ends the broadcast for good (a second call is a no-op); session.publish.dynamic(prefix:route:) claims prefix and every path beneath it ("" for everything). Hold the returned OriginDynamic while the claim should stay advertised, and reject the requests you will not serve. A route is a capability, not an inventory. announced(prefix:filter:) combines a literal root with an optional relative pattern and yields AnnounceEvents: .start, .update, or .end carrying an Announce, whose prefix stays relative to the origin and whose captures reports what the wildcards matched, or .live once every route live at subscribe time has been delivered. Paths with a .-prefixed segment below the prefix are hidden unless hidden: true.
For a self-signed relay on your own test network, try client.setTlsVerify(false) accepts any certificate; prefer setTlsRoots or a fingerprint anywhere else. Setters throw if a connect is in flight or after cancel().
Sessions reconnect with backoff when the transport drops and re-announce local broadcasts. session.epoch() counts the connections, 1 on the first, pairing with session.status() to log each reconnect; client.setBackoff tunes the pacing; and client.setQuicMaxStreams raises the peer's inbound stream cap.
The WebSocket fallback races QUIC after a 200 ms head start. client.setWebsocketEnabled(false) turns it off for a QUIC-only relay, and client.setWebsocketDelay(_:) changes the head start, in microseconds.
Server binds, generates or loads TLS, and hands you each request to accept() or reject(code:); request.transport is a Transport enum. JSON tracks take Codable types (publishJsonSnapshot(name:of:), subscribeJsonStream(name:as:)), and the rest of the shared feature list maps one to one: fetchGroup/fetchMediaGroup, dynamic() for tracks and dynamic(prefix:) for broadcasts, appendDatagram/ datagrams, setCatalogSection, demand() for used()/unused(). session.bandwidth() divides the connection's send estimate; pass it to encodeVideo / encodeAudio or reserve a share for an app-owned track. MoqError.isAuth and isShutdown classify errors. protocolError is the structured protocol failure (scope, verbatim code, kind) when the peer sent one. An error's description is the Rust error message.
encodeAudio encodes raw PCM inside the binding. Its codec is an object, AudioCodec.opus() or AudioCodec.aac(), and AudioEncoderOutput.frameDurationUs sets the Opus frame length: 2500, 5000, 10000, 20000 (the default), 40000, or 60000. 0 takes the codec's own frame, which AAC needs. AAC-LC encodes through the platform's encoder, so a host without one refuses it.
Audio channels also names the speaker layout, by the WAVE convention: 1 is mono, 2 stereo, 3 2.1, 4 quad, 5 5.0, 6 5.1, 7 6.1, and 8 7.1, interleaved front left, front right, center, LFE, back, then side. Decoding remixes to the count you ask for; past 8 channels the samples pass through but can't be remixed.
Each frame from decodeVideo owns its decoded picture until it is released, including after the consumer is cancelled. frame.pixels(format:) converts it on demand: .i420, or .rgba for four bytes a pixel. Release frames promptly, since held frames hold decoder buffers. resize is best effort: only NVDEC has a built-in scaler, and VideoToolbox is not it, so read each frame's own width() and height() rather than assuming it took. VideoDecoderOutput(surface: true) keeps the decoder's surface for frame.surface() instead of downloading it. Only macOS has one, so decodeVideo fails as unsupported elsewhere.
Connection stats
session.stats() returns a ConnectionStats snapshot. Each field is nil when the transport backend does not report it (native QUIC reports all of them; browser WebTransport reports few or none) or before it is available, which is not the same as zero.
| Field | Unit | Meaning |
|---|---|---|
rttUs | microseconds | Smoothed round-trip time. |
estimatedSendRateBps | bits per second | Send bandwidth from the congestion controller. |
estimatedRecvRateBps | bits per second | Receive bandwidth from MoQ PROBE. |
bytesSent | bytes | Total sent, including retransmissions and overhead. |
bytesReceived | bytes | Total received, including duplicates and overhead. |
bytesLost | bytes | Total lost, detected via retransmission or acknowledgement. |
packetsSent | datagrams | Total datagrams sent. |
packetsReceived | datagrams | Total datagrams received. |
packetsLost | datagrams | Total datagrams detected as lost. |
- API reference: Swift Package Index (DocC)
- Source:
swift/;just swift checkbuilds and tests on a Mac - Packages SPM resolves: moq-dev/moq-swift, moq-dev/moq-swift-ffi
Raw track publisher metadata has an optional maximum age. Omitting it imposes no publisher age limit; zero keeps the live edge. Local cache limits still apply, and media imports explicitly retain 30 seconds. See publisher retention.
Use try await session.shutdown() to drain finished tracks before disconnecting. It throws if delivery has not completed within one second. session.cancel(code: 0) remains immediate. Finish or abort live tracks before shutdown. IETF media streams are not drained yet.