TypeScript SDK changelog
The TypeScript packages release together at one version: @caprid/relay, @caprid/moq-core, @caprid/moq-media,
@caprid/moq-mediabunny, @caprid/relay-quic and its platform packages, and @caprid/relay-websocket. Before 1.0, a minor version can break the API;
each section says what changed and what to do about it. How to cut a release is in
docs/releasing.md.
@caprid/relay-websocket:withWebSocketFallback()races the two transports instead of waiting out WebTransport (#392). WebTransport dials first; if its handshake has not completed within the newwebSocketDelayMs(default 250 ms, as Happy Eyeballs, RFC 8305), the WebSocket dial starts alongside it, the first ready carries the session, and the other is closed. On a network that silently drops UDP, a connect no longer waits 4 s. Breaking:handshakeTimeoutMsis gone, and passing it throws aTypeError; usewebSocketDelayMs.onFallbacknow runs when a connection ends up on WebSocket, and the transport’scarrieris undefined until a dial wins.@caprid/relay-quic:nativeQuicTransport({ qlogDir }), or theRELAY_QUIC_QLOG_DIRenvironment variable, writes a qlog trace of every connection attempt into that directory, onerelay-quic-<ms>-<pid>-<n>.sqlogfile each, from quinn’sqlogfeature. Off by default. A trace holds packet numbers and types, sent packets’ sizes, losses, and RTT and congestion metrics, but no frames, stream data or token. A file that cannot be created is skipped and the dial goes on; a directory that cannot be created throws at once. The linux-x64-gnu addon grew from 2,766,896 to 2,912,848 bytes (+5.3%; gzip -9: 1,306,675 to 1,372,739 bytes). Nothing to change in an application.@caprid/relay-quic: a native QUIC connection now lets the relay have 1000 unidirectional streams open at once, up from quinn’s default of 100, and the newnativeQuicTransportoptionanticipatedConcurrentIncomingUnidirectionalStreamssets it. The relay sends each group on a stream of its own, and the credit for a closed stream returns over the client’s uplink: in relay-dev’s voice benchmark (P-06), a subscriber whose upload queued its uplink for about 2 s starved of stream credit at 100, and with more credit the median delay went from 2865 ms to 21.5 ms locally. The option is the exact limit, an integer from 1 to 65535; any other value throws aRangeErrorat once. Nothing to change in an application.@caprid/moq-media: fixes to the Opus loss concealment that 0.1.1 turned on. A decoder reset clears the record that the last packet was CELT-only, so the next gap can use in-band FEC again (it stayed off until a non-CELT packet decoded). The audio worklet, not the main thread’s report of the buffer, which is up to about 20 ms old, decides whether a fill is held: a loss while the buffer fills to its target is now concealed (it was left as silence), and a fill that came after the buffer ran dry is dropped (it played late, adding its length to the delay). A gap that spans a cut resets the decoder, as a gap over 1 s already did, so the frame after it is not decoded from libopus state that belongs before the gap.synthesizedUsandrecoveredUsno longer count fills the buffer dropped. Nothing to change in an application.@caprid/moq-media: parts for a quicker join.warmAudioPlayer(context)loads the jitter buffer’s worklet module and compiles libopus at once, ahead ofcreateAudioPlayer, which then loads neither again (libopus’s base64 is decoded withUint8Array.fromBase64where the engine has it:loadWasmOpus({ nativeBase64: true })).createFrameHold()keeps a frame reader’s frames until the player’s input exists, so a listener can subscribe to the audio before the player is built.input.joinPhasesandplayer.joinPhasesrecord when each step of the start happened. Nothing changes for a player that uses none of them. The WebCodecs example’slistenAudionow joins this way: it warms the player at the start, subscribes to the audio with the catalog and holds the frames until the input exists. In relay-dev’s voice benchmark, time to first audio on a clean path went from 627 to 249 ms (WebRTC 419; spec/media-layer.md, “Joining sooner”).@caprid/moq-media: timestamp playout, off by default. The frame sequencer’stimestampPlayout: { leadUs: () => input.playoutLeadUs }holds a frame that arrives ahead of its timestamp, as a text-to-speech agent’s bursts do, and releases it once its timestamp is within the lead of the playout clock; only a frame more thanhorizonUs(30 s) ahead resyncs forward, where without it one more than the target latency does. Delay that underruns added while a queue built is given back once frames come early again (held frames go out early by it, less 40 ms, and the jitter buffer trims it).stats.earlycounts the frames held, and the audio input’splayoutLeadUsis the lead that keeps its jitter buffer at its target. In relay-dev’s voice benchmark, bursty TTS played 100% with it against 5% without (WebRTC fed the same bursts: 39%); spec/media-layer.md has the numbers.@caprid/relay,@caprid/moq-media: track replacement for barge-in.createReplaceableTrack(publisher, { track, catalog: { service, build } })in@caprid/relay/mediapublishes a track whosereplace()registersaudio-2,audio-3, …, lists it in the catalog in the old one’s place, drops what the old track has not sent and closes it; frames written to a replacement wait for the relay’s first subscription (demandWaitMs, 2 s).findReplacementTrack(current, tracks)finds the replacement in a newer catalog. The audio input’sstats.cutHeardAtUsandonCutHeard(atUs)say when a cut reaches the speaker (the worklet’s report after a cut comes at once and gainscutId). The WebCodecs example’slistenAudiofollows a replacement (onTrackReplaced, with the phasesseen,stoppedandstarted, andlistener.track). spec/media-layer.md has the local and bench measurements.@caprid/moq-core,@caprid/relay:MessageParameters.maxAge(milliseconds, 0 to 2^53−1) is the MoQ Timestamp extension’s subscriber max age (parameter0x915C6): a publisher that honors it skips a group once a newer group has superseded it for that long. A session sends it on SUBSCRIBE and REQUEST_UPDATE only to a relay whose SETUP declared the extension’sSETUP_OPTION.MAX_AGE(0x915C4), never on FETCH, TRACK_STATUS or SUBSCRIBE_NAMESPACE, and leaves it off otherwise: moq-dev main declares it, moq-relay 0.17.x does not.RelaySubscriber.maxAgeSupportedandMoqtSession.maxAgeSupportedreport whether the relay did. Nothing changes for an application that sets nomaxAge.@caprid/relay:urlis optional on every connect call:connectRelaySubscriber,connectRelayPublisher,connectSupervisedSubscriberandconnectSupervisedPublisher, from@caprid/relayand@caprid/relay/node. Left out, the connection dialsDEFAULT_ENDPOINTrooted at the token’srootclaim, which it reads without verifying the token: the URLroomEndpointwould build from that root (#374). A quickstart is nowconnectSupervisedSubscriber({ getToken }), with namespaces relative to the token’s root.getTokenis then required. A token that is not a JWT, has no stringrootclaim, or has a rootroomEndpointrefuses throws aTypeErrororRangeErrorbefore the transport is used. A supervisor keeps the endpoint its first connection dialed for every reconnect and rotation. A call that passesurlis unchanged.@caprid/relay: a token’s claims are read only from a JWT payload in unpadded base64url whose JSON starts at its first byte. A padded payload, one in standard base64 with+or/, or one with a byte order mark has no claims, so itsexpiresAtisundefinedinstead of itsexp. The relay refuses such a token anyway.
@caprid/relay:roomEndpoint(room)roots the hosted Relay,https://relay.mux.global, so a quickstart passes only the token’s root.roomEndpoint(endpoint, room)works as before. The endpoint is exported asDEFAULT_ENDPOINT, as the Python SDK already did.@caprid/moq-core: relay-side test support (#367).createMoqtSessiontakesperspective: 'client' | 'server'(default'client', unchanged): a'server'session allocates odd Request IDs from 1 and closes withINVALID_REQUEST_IDon an odd one from its peer, so it can stand in for the relay against a publish session over an in-memory pair without rewriting Request IDs on the wire. A new@caprid/moq-core/wireentry point exports the codec such a test drives the session with (control-message encoders and decoder, the deframer, subgroup and fetch stream readers, varints,StreamReader); import from it instead ofdist/protocol/*. Nothing changes for@caprid/relay.@caprid/relay,@caprid/moq-core: a publisher connection answers the relay’s TRACK_STATUS (draft-22 §9.13) for its tracks instead of refusing it withNOT_SUPPORTED: TRACK_STATUS_OK with the track’s properties and, once the track has written an object, LARGEST_OBJECT; an unknown or closed track getsDOES_NOT_EXIST, as a SUBSCRIBE does. moq-relaymainasks a publisher for a track’s status before it subscribes on behalf of another relay, and a track whose publisher refuses does not cross two relays there. Nothing changes for an application. In@caprid/moq-core,RegisteredTrack.objectPublished(location)records what a TRACK_STATUS_OK reports as LARGEST_OBJECT (a track withgetLargestObjectreports that instead, as its SUBSCRIBE_OK does), andencodeRequestOktakes the Track Properties as a third argument.@caprid/moq-media: the audio player’s jitter buffer gives back what a delay spike added in seconds rather than a minute: +40 ms per underrun, −10 ms after each 2 s of smooth playback, a 20 ms floor (was +20 ms, −10 ms per 8 s, 40 ms), and trims of 5 ms or more. On stage-2 netbench arrivals replayed offline it held 9–74 ms less (clean 9, busy Wi-Fi 28, LEO 42, LTE 74) at about the same drops, and 1.5 drops a minute more under a bulk download’s queue sawtooth; passminJitterTargetUs: 40_000for the old floor.
@caprid/moq-media: lost Opus frames are filled rather than played as silence. Opus inputs decode with libopus compiled to WebAssembly (116 KB, embedded), which conceals a missing frame (packet-loss concealment, up to 120 ms a gap) and rebuilds it from the next frame’s in-band FEC when the publisher’s encoder sent one. StatssynthesizedUsandrecoveredUscount them.createAudioPlayer({ context, lossConcealment: false })decodes with WebCodecs as before, as does any input where WebAssembly is unavailable or the codec is not Opus.@caprid/moq-core: publish-session hooks for embedders such as Video.js. A registered track’sgetLargestObjectputsLARGEST_OBJECTin SUBSCRIBE_OK and REQUEST_OK; with it, a nonempty fill request gets a fill fetch stream that is reset after its FETCH_HEADER and counted in PUBLISH_DONE.RegisteredTrack.end({ after })holds the subscriptions’ end until a last group is written, also during an orderly close. Nothing changes for@caprid/relay, which uses neither.@caprid/moq-core: a REQUEST_UPDATE carryingFILL_PARAMETERSis acknowledged instead of ending the subscription withUPDATE_FAILED. moq-relay sends no REQUEST_UPDATE, so the relay path is unchanged.@caprid/moq-core:MoqtTransport.protocolis optional; a transport without it gets themoqt-22codec. The index also exportsTRACK_PROPERTY,compareLocations,isEmptyFetchRange,encodeFetchHeader,isRetryableRequestErrorCode,isRetryablePublishDoneStatus,utf8Decode,CLOSE_FLUSH_TIMEOUT_MSandCLOSE_END_ACK_TIMEOUT_MS.
@caprid/relay:connectRelayPublisherandconnectRelaySubscriberfor browsers over WebTransport (Chromium, and Firefox 155 or later), and@caprid/relay/nodefor Node 24+ over native QUIC.@caprid/relay:roomEndpoint(endpoint, room)builds the recommended room-rooted endpoint, the endpoint with the token’s root appended to its path. It keeps the query and encodes nothing: moq-relay reads the path without percent-decoding it, so a field that would need encoding (a space,%,/,*, non-ASCII,.or..) throws aRangeError, and that room is reached from the bare host.@caprid/relay-websocket: a transport adapter for QMux over WebSocket, the relay’s TCP 443 path for networks that block UDP and browsers without a usable WebTransport. PasscreateWebSocketTransport()ascreateTransportto always use it, orwithWebSocketFallback()to dial WebTransport first and WebSocket when that handshake fails or takes longer thanhandshakeTimeoutMs(4 s). Nothing falls back unless you pass one. Over WebSocket a refused token isTRANSPORT_ERROR, notUNAUTHORIZED, and every stream shares one TCP connection.RelayTransportFactory’sframingtakes'websocket', which sends the same SETUP as'webtransport'.@caprid/relay/media: the media publishing policy Python has ascaprid_relay.media.createFrameWriter(track, options)maps encoded frames onto groups (a group per keyframe, or per frame) and can be called from a WebCodecs output callback without awaiting: frames wait in a bounded backlog (maxBacklog), and when it overflows the writer drops rather than queues. Every frame gets oneFrameOutcome, andkeyframeNeeded/onKeyframeNeededsay when the encoder should force a keyframe.createCatalogServiceandcreateDocumentServiceserve the MSF catalog and the media timeline on every relay subscription. The WebCodecs example publishes through all three.@caprid/relay/media:mediaTrackProperties('audio' | 'video', options)gives a media track a default subgroup delivery timeout, so a stall costs stale frames instead of lasting latency: 500 ms for audio, and for video the target latency less the keyframe interval, never less than one interval (one interval by default). Pass its result toregisterTrackbesidelocTrackProperties(); video needskeyframeIntervalUs. The WebCodecs example uses it. It also sets a default publisher priority (0x0E) that puts audio ahead of video, 16 for audio and 96 for video, whichpublisherPriorityreplaces. moq-relay 0.17 passes it to viewers but orders delivery by their subscriber priority alone, so it changes no delivery there yet.@caprid/relay/media:mediaSubscribeParameters('audio' | 'video', overrides?)gives a viewer’s subscription the matching subscriber priority, 16 for audio and 96 for video, the one moq-relay 0.17 orders delivery by. Pass it asparameterstosubscribe,wantorswitchTo;overridesadds other parameters, and itssubscriberPriorityreplaces the default.- The publisher opens each data stream of a track with a publisher priority (
0x0E) with a WebTransport send order from it, so a congested uplink sends audio before video, and both after the control streams. Through an 800 kbit/s uplink cap it brought a Firefox publisher’s audio p99 from 264–302 ms to 108–109 ms. Firefox 155 schedules by it; Chromium 153 ignores it.@caprid/relay-websocketand@caprid/relay-quicschedule by it too. A transport adapter’screateUnidirectionalStreamnow takes{ sendOrder }(MoqtSendStreamOptions, TA-28); an adapter that cannot schedule ignores it. openGroup({ payload, properties })opens a group with its first object, so that object’s delivery timeout counts the wait for demand, settling room and the stream; a bareopenGroup()has no object to time. The frame writer opens every group this way, so a frame whose open fails reports nogroupId, andgroupscounts only opens that returned a group.@caprid/moq-media: transcripts.buildTranscriptSegmentandparseTranscriptSegmentwrite and read the transcript track’s segments (TRANSCRIPT_TRACK,'transcript'): the text of a talker’s audio fromfromUs, sent in interim and final versions that share aseq.createTranscript()keeps a listener’s segments, retracts what an interrupt cut at the playout position, and gives the lines heard by a position. The WebCodecs example’slistenAudiokeeps one aslistener.transcriptwhen the catalog lists the track, and the voice bot page shows the bot’s words as its audio plays.@caprid/moq-media: interrupts.buildInterruptandparseInterruptwrite and read the interrupt track’s messages (INTERRUPT_TRACK,'interrupt'), which tell a listener to drop audio a talker already sent; a parsedInterruptis anAudioCutRange, soinput.cut(interrupt)applies it. The WebCodecs example’slistenAudioapplies them when the catalog lists the track, andpublishCanvastakesdatatracks to list. A new voice bot page (examples/voice-bot) talks to the Python bot through them, on the WebCodecs example’s new audio-onlypublishMicrophone.@caprid/moq-media: a headless audio player.createAudioPlayer({ context })mixes any number of inputs into oneoutputnode; eachplayer.addInput({ clock, decoderConfig })decodes with WebCodecs, resets its decoder at discontinuities, holds audio in an adaptiveAudioWorkletjitter buffer (60 ms to start, 40–400 ms, under the clock’s target latency), and reports what is audible to the playout clock.input.pushis a frame reader’sonFrame;input.cut({ fromUs, untilUs })drops received audio in a timeline range. AlsocreateEchoCancellationLoopbackfor Chromium’s echo canceller,audioDecoderConfigFromTrack,opusPacketDurationUs, andAUDIO_ONLY_TARGET_LATENCY_US(500 ms) for an audio-only clock, whose 2 s default otherwise waits for keyframes.createRenderClockmoved into the package from the WebCodecs example, which gains an audio-only listener,listen-audio.ts.input.playbackRate(0.5 to 2) nudges latency without cuts or silence, and an input’sclockis anyAudioInputClock, which each report hands the input’s stats, now withplayStarts,heldUntilUsanddryAtUs.- Reading frames in decode order within a latency budget.
@caprid/moq-media’screateFrameSequencer({ clock, grouping })orders one track’s objects across group streams, waits for a late group only while it can still matter, skips to the next keyframe past the deadline, drops late audio, and marks eachgap,resyncorrestartwhere a decoder resets.@caprid/relay/media’screateFrameReader(sequencer, { onFrame })is the subscription’s or want’s handlers that drive it. The playout clock holds the budget:targetLatencyUs, by default twice the longest recent keyframe interval (reportKeyframeInterval), 2 s before one is known; wall pacing now re-anchors on a late frame only beyond it, instead of beyondmaxVideoDelayUs. Both examples now force a keyframe at least every second. - Frame grouping plays every frame of a group that holds several, one per object, as
openGroup()writes them: the sequencer’s position is a group and an object, so a group’s later objects are no longer dropped as behind its first, and an object skipped inside a group is agap. Groups of one frame, as the frame writer sends audio, behave as before. - Voice delivery per direction (relay-3cnp, the voice benchmark).
createFrameSequencertakes three per-track options, each off by default:lateDropUs(frame grouping) replaces the clock’s late-drop limit for one track,Infinitydropping nothing;reorderWindowUs(frame grouping) holds newer audio that long for a missing earlier group instead of dropping the latecomer; andcatchUpAfterUsmakespoll()returncatchUp: trueonce audio has kept arriving late that long.createFrameReader’sonCatchUpreceives it, and the supervised subscriber’s newcatchUp(key)replaces the want’s subscription at the live edge, which drops the relay’s backlog;onRestartedreports the new reason'catch-up'. A delivered frame after skipped groups carriesmissingGroups(MissingGroups:firstGroupId,lastGroupId). moq-relay 0.17.5 and 0.17.7 ignore a subscriber’s delivery timeout, so on those releases these are how a subscriber bounds its delay; they work with a relay that honours it too (spec/relay-interop.md). TypeScript only. @caprid/relay:subscriber.namespaceEvents({ prefix })discovers which namespaces under a prefix are announced. The subscriber’s SETUP now carries the SOLICIT option, so the relay advertises namespaces only when asked. A prefix overlapping one the connection already discovers is refused with the newPREFIX_OVERLAPcode (draft-22 §9.15).@caprid/moq-coregainssession.subscribeNamespaceandSETUP_OPTION.SOLICIT.@caprid/relay: opt-in supervised connections.connectSupervisedPublisherreconnects with backoff, serving the root namespace plus a fresh generation field per connection, and rebinds the same track writers to every generation; a write between generations fails with the newRECONNECTINGcode and is never replayed.connectSupervisedSubscriberkeeps tracks subscribed throughwant(key, options, handlers): it retries the announce race, resubscribes after a done, a rejection or a stale track, moves to replacement connections, follows a supervised publisher’s newest generation through discovery, and reportsonRestarted. AnonStatusoronGenerationthat throws stops the supervisor with that error; anonRestartedthat throws is logged and the want stays subscribed. Both are in@caprid/relay/nodetoo. The primitives are unchanged. The examples’ sharedannounce-race.jsis retired: the demo and the live smoke test subscribe throughwant.@caprid/relay:subscriber.subscribeSwitchable(options, handlers)moves one set of handlers between tracks make-before-break.switchTo(track, { parameters, until, drainMs, signal })subscribes to the new track while the old one keeps delivering, cuts over on the first objectuntilaccepts, reportsonSwitched, and can keep the old subscription open briefly for groups still in flight. It is how to change a track, filter or priority, whichupdate()cannot do against moq-relay.@caprid/relay: a supervised want moves make-before-break. A newer generation, or wanting the key again with another track or parameters, opens the replacement while the current subscription keeps delivering, and cuts over on the first objectuntilaccepts. The newWantOptionsfieldsuntilanddrainMswork as they do onswitchTo, and the newonSwitchedhandler receives aWantSwitchedInfo(from,to,subscribed). A generation move no longer reportsonRestarted({ reason: 'generation' }); handleonSwitchedinstead, where decoders reset. Wanting a key again with onlystaleAfterMschanged now swaps it without resubscribing. Another namespace orgenerationsstill replaces the want.@caprid/relay: sessions end at the token’s expiry, and the supervisors now rotate before it.getTokenmay return aRelayCredential({ token, expiresAt }); every connection exposesexpiresAt, from that or the JWT’sexpclaim. Both supervisors takerotateBeforeMs(default 60 s) androtate(), and replace their connection make-before-break: the publisher moves each track to the next generation once it has demand there, and every want moves its subscription to the new connection as a move does. A want whose target is unchanged cuts over on the first object the old subscription did not deliver, with no callback, so nothing is lost or repeated. A session closed withUNAUTHORIZED(0x2) after SETUP, which is how moq-relay ends an expired or revoked session, is nowUNAUTHORIZEDin phaseconnectionrather thanTRANSPORT_ERRORor a clean close.@caprid/relay-quic: the native QUIC transport adapter for Node, with prebuilt binaries for Linux (x64 and arm64, glibc and musl), macOS (arm64 and x64) and Windows (x64).@caprid/moq-core: the transport-independent MOQT sessions the SDK is built on.- MOQT draft-22 by default: connections offer
moqt-22, thenmoqt-20as a fallback, and speak whichever the relay selects. The drafts differ on the wire only in the LOCATION_FILTER parameter, which@caprid/moq-corenow encodes for the negotiated draft: its encoders and decoders that carry message parameters take a trailingprotocolargument (defaultmoqt-22), and it exportsMOQT_PROTOCOL_IDS.MOQT_PROTOCOL_IDis nowmoqt-22. moq-relay 0.17.0 frames LOCATION_FILTER the draft-20 way onmoqt-22, so a Next Object filter closes the session there; use a relative start of 0. @caprid/moq-mediaand@caprid/moq-mediabunny: LOC and MSF format helpers, and an optional Mediabunny adapter.buildMsfCatalogleavesisCompleteout, since a live broadcast is not complete (msf-01 §5.1.3), andbuildMsfEndCatalogbuilds the §11.3 catalog that ends a broadcast:isComplete: trueand no tracks.@caprid/moq-media: one timeline, and a playout clock for audio/video sync.createBroadcastClockstamps every track in microseconds since the Unix epoch, so broadcasts line up with each other (sourceTimelinewithcalibratefor anAudioContextclock orMediaStreamTrackProcessortimestamps, so the publisher path works in Chromium, Firefox and Safari;sampleTimelinefor sample counters).createPlayoutClocktakes audio reports and answersdue(timestampUs)with wait, show, or drop, falling back to wall-clock pacing without audio. Both are DOM-free.buildMsfCatalogputs video and audio in one render group (renderGroup: 1, msf-01 §5.2.11), andparseMsfCatalogreturnsrenderGroup.@caprid/moq-media:buildMsfCatalogdescribes any number of video and audio tracks.mediatakesMediaTrackInfoentries ({ role: 'video' | 'audio', ... }), and every video or audio description takesrenderGroup(default 1) andaltGroup(msf-01 §5.2.12) for simulcast layers; members of an alternate group must share a role and a render group.videoandaudioare unchanged.parseMsfCatalogreturnsaltGroup, andgroupMsfTrackslists the sets of tracks a viewer picks one of.@caprid/moq-media: format helpers for msf-01’s Media Timeline track, which maps media time to wall-clock time.mediaTimelineEntryturns a timeline timestamp and a group into a record,buildMediaTimelineandparseMediaTimelinewrite and read a timeline object, andbuildMsfCatalogannounces the track throughmediaTimeline(packagingandrolemediatimeline,dependson the video and audio tracks by default).parseMsfCatalogreturnsdependsandmimetype.createMediaTimeline({ maxEntries })keeps one broadcast’s records (512 by default, oldest dropped) and returns the document to send for each new entry, and the WebCodecs example publishes the track.@caprid/moq-media: capture times on one epoch, and a Timescale-aware reader. Source timelines take a settablecaptureLatencyUs, subtracted from every timestamp, for the input delay no browser reports.locTimestampUsreads an object’s LOC Timestamp in microseconds, applying the object’s or the track’s Timescale.