@moqtap/codec 0.16
Encode, decode, and validate MoQT messages in JavaScript/TypeScript.
What it does
Section titled “What it does”@moqtap/codec is a complete MoQT protocol library with two layers:
- Codec — stateless encode/decode of all control messages and data streams
- Session FSM — protocol state machine that validates message ordering, tracks subscriptions, publishes and fetches, and emits side effects
Supports drafts 07 through 23 of MoQ Transport. Zero runtime dependencies. Works in Node.js, Bun, Deno, and browsers.
Installation
Section titled “Installation”npm install @moqtap/codecQuick start
Section titled “Quick start”MoQT is not yet an RFC, so the draft is always explicit:
import { createCodec } from '@moqtap/codec';
const codec = createCodec({ draft: '23' }); // '07' through '23'
// Encode a message to wire bytesconst bytes = codec.encodeMessage({ type: 'subscribe', request_id: 0n, track_namespace: ['live'], track_name: 'video', parameters: {},});
// Decode wire bytes back to a typed messageconst result = codec.decodeMessage(bytes);if (result.ok) { console.log(result.value.type); // 'subscribe'} else { console.error(result.error.code); // e.g. 'UNEXPECTED_END'}Message fields use snake_case, as in the draft text. Each draft has its own message types, so a message’s shape depends on the draft you pick.
Entry points
Section titled “Entry points”| Import path | Contents |
|---|---|
@moqtap/codec | createCodec({ draft }), DRAFT_VERSIONS, cross-draft accessors, shared types |
@moqtap/codec/session | createSessionState({ codec: { draft }, role }) |
@moqtap/codec/draft07 … /draft23 | One draft’s codec, message types, constants and error-code registries |
@moqtap/codec/draft07/session … /draft23/session | One draft’s session state machine |
A draft entry point gives the tightest types and pulls in only that draft:
// Draft-23import { createDraft23Codec, encodeMessage, decodeMessage, createStreamDecoder, PROTOCOL_STRING, // 'moqt-23'} from '@moqtap/codec/draft23';
// Draft-14import { createDraft14Codec, encodeMessage, decodeMessage, createStreamDecoder, MESSAGE_ID_MAP,} from '@moqtap/codec/draft14';Every draft entry point exports createDraft{NN}Codec(), encodeMessage, decodeMessage, createStreamDecoder, redactAuthTokens, DRAFT_VERSION, MESSAGE_ID_MAP and MESSAGE_TYPE_MAP, its message types and its error-code registries.
Draft versions
Section titled “Draft versions”Through draft-14 the version is negotiated in CLIENT_SETUP and SERVER_SETUP, and 0xff0000NN travels on the wire. Those are DRAFT_VERSIONS['07'] through DRAFT_VERSIONS['14']. From draft-15 the version is negotiated by ALPN or WT-Available-Protocols as the string moqt-NN, and no version number is sent. The entries above '14' are identifiers only, not values a peer sends. Drafts 20 to 23 export the protocol string as PROTOCOL_STRING.
Codec API
Section titled “Codec API”Control messages
Section titled “Control messages”const codec = createDraft14Codec();
// Single messageconst bytes = codec.encodeMessage(message);const result = codec.decodeMessage(bytes); // { ok: true, value, bytesRead } or { ok: false, error }
// Streaming decoder for incremental parsingconst decoder = codec.createStreamDecoder();// TransformStream<Uint8Array, Draft14Message>On draft-23 the Message Length is a varint, and a message may be as long as the receiver’s MAX_CONTROL_MESSAGE_SIZE (Setup Option 0x09, 65535 by default). encodeMessage, decodeMessage and createStreamDecoder take an optional { maxControlMessageSize }. When given, a longer message is refused. SETUP is always held to 65535, and when the option is omitted no other message is. Pass your own advertised value when decoding and the peer’s when encoding. The draft-23 session state machine reports both as localMaxControlMessageSize and peerMaxControlMessageSize.
On draft-23 a failed decode also carries error.closeCode, the session termination code the draft names for the failure (for example SessionTerminationCode.MalformedMessage, 0x300n). It is absent when the frame is only incomplete, and when the code depends on the receiver’s role: pass { role: 'client' } or { role: 'server' } to decodeMessage or createStreamDecoder to have a SETUP Authorization Token refusal named as well. A subgroup or fetch stream decoder that is closed in the middle of an Object fails with TruncatedObject (0x203n).
A draft-23 REQUEST_OK does not say which request it answers, so the decoder accepts any parameter that some REQUEST_OK may carry. checkRequestOk(kind, message) applies the list for one response, such as 'publish_ok' or 'fetch_ok', and checkRequestUpdate(target, message, fromSubscriber) does the same for REQUEST_UPDATE. Each returns { closeCode, reason } or null. The session state machine applies both when it knows the request stream.
Data streams
Section titled “Data streams”// Subgroup streamscodec.encodeSubgroupStream(stream);codec.decodeSubgroupStream(bytes);
// Datagramscodec.encodeDatagram(obj);codec.decodeDatagram(bytes);
// Fetch streamscodec.encodeFetchStream(stream);codec.decodeFetchStream(bytes);
// Streaming data decoders (drafts 14 and later)codec.createSubgroupStreamDecoder();codec.createFetchStreamDecoder();codec.createDataStreamDecoder();Drafts 08 and later also have decodeDataStream(streamType, bytes), with streamType one of 'subgroup', 'datagram' or 'fetch'. Drafts 08 to 10 add 'datagram_status', with encodeDatagramStatus and decodeDatagramStatus.
Object properties
Section titled “Object properties”The data stream decoders return each Object’s property block (Extension Headers before draft-17) as raw bytes in extensionData, with extensionDataByteOffset giving where it starts. Decode it with the draft’s decodeObjectProperties:
const result = codec.decodeObjectProperties(obj.extensionData);if (result.ok) { for (const p of result.value) { // p.type: bigint; p.value for an even type, p.bytes for an odd one; // p.properties for the contents of Immutable Properties (0xB) }}
// The reverse, for building an Object to encodeconst extensionData = codec.encodeObjectProperties([{ type: 0x3cn, value: 2n }]);The list keeps wire order, repeated types, odd-typed values and unknown types. A block malformed by the draft’s rules, such as a property running past the block or a value longer than the draft allows, is a decode error.
On draft-23, malformedTrackReason(stream) checks the Object Properties of a decoded subgroup stream, datagram or fetch stream for the conditions that make a Track malformed: a Property block that cannot be parsed, Immutable Properties nested in Immutable Properties, a Track-only or Mandatory Track Property (0x4000-0x7FFF, grease excepted) on an Object, a repeated Property, a Prior Group ID Gap or Prior Object ID Gap larger than the Group or Object ID, or two Objects of one Group with different Prior Group ID Gaps. It also reports a Prior Object ID Gap covering the Object before it on a subgroup stream, and on a fetch stream a gap covering the Group or Object before it, and a Group or Object within the gap the one before it communicated. malformedTrackPropertiesReason(message.track_properties) does the same for the Track Properties of a SUBSCRIBE_OK, PUBLISH or REQUEST_OK: a repeated Property, an Object-only Property (0x3C, 0x3E), or Immutable Properties that cannot be parsed. The decoder keeps a repeated Track Property after the first in unknown. Both return the reason or null. A malformed Track is not a decode error.
On drafts 14 to 22, malformedTrackReason(stream) checks only the Prior Group ID Gap and Prior Object ID Gap: one larger than the Group or Object ID, one an Object carries twice, or two Objects of one Group with different Prior Group ID Gaps. On drafts 14 and 15 it also reports a Prior Object ID Gap covering the Object before it on a subgroup stream. On a fetch stream it also reports, on drafts 14 and 15, a gap covering the Group or Object before it, and on drafts 14 to 17, a Group or Object within the gap the one before it communicated.
On drafts 11 to 13, malformedTrackReason(stream) checks the Prior Group ID Gap (0x40): an Object whose every value is larger than the Group ID, or Objects of one Group with no Prior Group ID Gap value in common. On a fetch stream it also reports a Group within the gap the Group before it communicated.
VarInt
Section titled “VarInt”@moqtap/codec/draft07 exports the variable-length integer helpers:
import { encodeVarInt, decodeVarInt } from '@moqtap/codec/draft07';
const bytes = encodeVarInt(16384n); // 4-byte encodingconst result = decodeVarInt(bytes); // { ok: true, value: 16384n, bytesRead: 4 }Reading across drafts
Section titled “Reading across drafts”A field can move between messages from one draft to the next. A track alias travels in SUBSCRIBE through draft-11 and in SUBSCRIBE_OK from draft-12. The root entry point answers these questions for any draft:
import { joiningRequestIdOf, requestIdOf, trackAliasOf, trackOf } from '@moqtap/codec';
const decoded = codec.decodeMessage(bytes);if (decoded.ok) { requestIdOf(decoded.value); // bigint, whichever name the draft uses trackAliasOf(decoded.value); // bigint trackOf(decoded.value); // { namespace, name } joiningRequestIdOf(decoded.value); // for a joining FETCH, the request it continues}Each returns undefined when the message does not carry the field. requestIdOf is undefined for draft-17 and later responses, which carry no request ID.
Redacting authorization tokens
Section titled “Redacting authorization tokens”redactAuthTokens(bytes) returns a control frame with every authorization token value overwritten with REDACTION_FILL (0x00). The frame keeps its length.
import { redactAuthTokens } from '@moqtap/codec/draft23';
const { bytes: clean, redacted, incomplete, decoded } = redactAuthTokens(frame);redacted counts the overwritten values. Do not treat clean as safe when incomplete is true. When decoded is false the frame did not parse to the end, and redacted === 0 does not mean the frame holds no token. Drafts 07 to 10 overwrite the whole AUTHORIZATION_INFO value. Drafts 11 and later overwrite the token value only.
Session state machine
Section titled “Session state machine”The session FSM validates protocol message ordering and tracks the lifecycle of subscriptions, publishes, and fetches. It does not perform I/O — you feed it messages and it tells you whether they’re legal.
import { createSessionState } from '@moqtap/codec/session';
const session = createSessionState({ codec: { draft: '23' }, role: 'client' });
// Or, from a draft entry point:import { createDraft23SessionState } from '@moqtap/codec/draft23/session';const session23 = createDraft23SessionState('client');Core methods
Section titled “Core methods”| Method | Description |
|---|---|
send(message) | Validate and apply an outgoing message. Returns TransitionResult. |
receive(message) | Validate and apply an incoming message. Returns TransitionResult. |
validateOutgoing(message) | Check if a message is legal to send without changing state. |
reset() | Return to idle phase, clear all tracked state. |
Properties
Section titled “Properties”| Property | Type | Description |
|---|---|---|
phase | SessionPhase | Current session phase |
role | 'client' or 'server' | The local endpoint’s role |
subscriptions | ReadonlyMap<bigint, SubscriptionState> | Subscription states keyed by ID |
announces | ReadonlyMap<string, AnnounceState> | Namespace announcement states |
legalOutgoing | ReadonlySet | Message types valid to send now |
legalIncoming | ReadonlySet | Message types valid to receive now |
TransitionResult
Section titled “TransitionResult”Every send() and receive() call returns a result:
// Success{ ok: true, phase: 'ready', sideEffects: [...] }
// Protocol violation{ ok: false, violation: { code: 'ROLE_VIOLATION', message: '...', currentPhase: 'ready', offendingMessage: 'subscribe_ok' } }Violation codes: MESSAGE_BEFORE_SETUP, UNEXPECTED_MESSAGE, DUPLICATE_SUBSCRIBE_ID, DUPLICATE_REQUEST_ID, UNKNOWN_SUBSCRIBE_ID, UNKNOWN_REQUEST_ID, ROLE_VIOLATION, STATE_VIOLATION, SETUP_VIOLATION.
On draft-23 a violation also carries closeCode where the draft names the session termination code for it.
Session phases
Section titled “Session phases”idle → setup → ready → draining| Phase | Entered when | Legal messages |
|---|---|---|
idle | Initial state | Setup messages |
setup | First setup message sent or received | The peer’s setup message |
ready | Setup complete | All control messages |
draining | goaway sent/received | Existing subscriptions only |
Through draft-16 setup is CLIENT_SETUP then SERVER_SETUP. From draft-17 each end sends one SETUP, and the session is ready once both have been seen.
Side effects
Section titled “Side effects”The FSM emits side effects to notify your application of state changes:
const result = session.receive(message);if (result.ok) { for (const effect of result.sideEffects) { switch (effect.type) { case 'session-ready': // Setup complete, can send control messages break; case 'session-draining': // GOAWAY seen, effect.goAwayUri has the new URI break; case 'subscription-activated': // SUBSCRIBE_OK received, effect.subscribeId break; case 'subscription-ended': // Subscription closed, effect.subscribeId and effect.reason break; case 'announce-activated': case 'announce-ended': // effect.namespace break; case 'publish-activated': case 'publish-ended': case 'fetch-activated': case 'fetch-ended': // effect.requestId break; case 'fill-fetch-stream-opened': // drafts 20–23 case 'fill-fetch-stream-closed': // drafts 20–23 case 'subscription-state-notified': // drafts 20–23 break; } }}Example: full session lifecycle
Section titled “Example: full session lifecycle”import { FilterType } from '@moqtap/codec/draft14';import { createDraft14SessionState } from '@moqtap/codec/draft14/session';
const session = createDraft14SessionState('client');
// Setupsession.send({ type: 'client_setup', supported_versions: [0xff00000en], parameters: {} });session.receive({ type: 'server_setup', selected_version: 0xff00000en, parameters: {} });// session.phase === 'ready'
// Subscribeconst sub = session.send({ type: 'subscribe', request_id: 0n, track_namespace: ['chat'], track_name: 'messages', subscriber_priority: 128, group_order: 0, forward: 1n, filter_type: FilterType.NextGroupStart, parameters: {},});// sub.ok === true, subscription is now 'pending'
session.receive({ type: 'subscribe_ok', request_id: 0n, track_alias: 1n, expires: 0n, group_order: 1, content_exists: 0n, parameters: {},});// subscription is now 'active', sideEffects includes 'subscription-activated'
// Message types legal to send nowsession.legalOutgoing; // Set { 'goaway', 'subscribe', 'subscribe_update', 'unsubscribe', ... }Decode errors
Section titled “Decode errors”decodeMessage and the other one-shot decoders return { ok: false, error } instead of throwing. error is a DecodeError with a code and the byte offset where decoding stopped. Streaming decoders error the stream with the same DecodeError.
| Code | Meaning |
|---|---|
UNEXPECTED_END | Truncated input |
INVALID_VARINT | Malformed variable-length integer |
UNKNOWN_MESSAGE_TYPE | Unrecognized message type ID |
INVALID_PARAMETER | Malformed parameter encoding |
CONSTRAINT_VIOLATION | Value outside legal range |