Skip to content

@moqtap/codec 0.16

Encode, decode, and validate MoQT messages in JavaScript/TypeScript.

npm | GitHub

@moqtap/codec is a complete MoQT protocol library with two layers:

  1. Codec — stateless encode/decode of all control messages and data streams
  2. 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.

Terminal window
npm install @moqtap/codec

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 bytes
const bytes = codec.encodeMessage({
type: 'subscribe',
request_id: 0n,
track_namespace: ['live'],
track_name: 'video',
parameters: {},
});
// Decode wire bytes back to a typed message
const 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.

Import pathContents
@moqtap/codeccreateCodec({ draft }), DRAFT_VERSIONS, cross-draft accessors, shared types
@moqtap/codec/sessioncreateSessionState({ codec: { draft }, role })
@moqtap/codec/draft07 … /draft23One draft’s codec, message types, constants and error-code registries
@moqtap/codec/draft07/session … /draft23/sessionOne draft’s session state machine

A draft entry point gives the tightest types and pulls in only that draft:

// Draft-23
import {
createDraft23Codec,
encodeMessage,
decodeMessage,
createStreamDecoder,
PROTOCOL_STRING, // 'moqt-23'
} from '@moqtap/codec/draft23';
// Draft-14
import {
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.

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.

const codec = createDraft14Codec();
// Single message
const bytes = codec.encodeMessage(message);
const result = codec.decodeMessage(bytes); // { ok: true, value, bytesRead } or { ok: false, error }
// Streaming decoder for incremental parsing
const 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.

// Subgroup streams
codec.encodeSubgroupStream(stream);
codec.decodeSubgroupStream(bytes);
// Datagrams
codec.encodeDatagram(obj);
codec.decodeDatagram(bytes);
// Fetch streams
codec.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.

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 encode
const 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.

@moqtap/codec/draft07 exports the variable-length integer helpers:

import { encodeVarInt, decodeVarInt } from '@moqtap/codec/draft07';
const bytes = encodeVarInt(16384n); // 4-byte encoding
const result = decodeVarInt(bytes); // { ok: true, value: 16384n, bytesRead: 4 }

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.

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.

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');
MethodDescription
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.
PropertyTypeDescription
phaseSessionPhaseCurrent session phase
role'client' or 'server'The local endpoint’s role
subscriptionsReadonlyMap<bigint, SubscriptionState>Subscription states keyed by ID
announcesReadonlyMap<string, AnnounceState>Namespace announcement states
legalOutgoingReadonlySetMessage types valid to send now
legalIncomingReadonlySetMessage types valid to receive now

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.

idle → setup → ready → draining
PhaseEntered whenLegal messages
idleInitial stateSetup messages
setupFirst setup message sent or receivedThe peer’s setup message
readySetup completeAll control messages
draininggoaway sent/receivedExisting 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.

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;
}
}
}
import { FilterType } from '@moqtap/codec/draft14';
import { createDraft14SessionState } from '@moqtap/codec/draft14/session';
const session = createDraft14SessionState('client');
// Setup
session.send({ type: 'client_setup', supported_versions: [0xff00000en], parameters: {} });
session.receive({ type: 'server_setup', selected_version: 0xff00000en, parameters: {} });
// session.phase === 'ready'
// Subscribe
const 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 now
session.legalOutgoing; // Set { 'goaway', 'subscribe', 'subscribe_update', 'unsubscribe', ... }

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.

CodeMeaning
UNEXPECTED_ENDTruncated input
INVALID_VARINTMalformed variable-length integer
UNKNOWN_MESSAGE_TYPEUnrecognized message type ID
INVALID_PARAMETERMalformed parameter encoding
CONSTRAINT_VIOLATIONValue outside legal range