Skip to content

@moqtap/codec 0.13

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 22 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: '22' }); // '07' through '22'
// 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 … /draft22One draft’s codec, message types, constants and error-code registries
@moqtap/codec/draft07/session … /draft22/sessionOne draft’s session state machine

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

// Draft-22
import {
createDraft22Codec,
encodeMessage,
decodeMessage,
createStreamDecoder,
PROTOCOL_STRING, // 'moqt-22'
} from '@moqtap/codec/draft22';
// 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 22 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>
// 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.

@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/draft22';
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: '22' }, role: 'client' });
// Or, from a draft entry point:
import { createDraft22SessionState } from '@moqtap/codec/draft22/session';
const session22 = createDraft22SessionState('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.

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–22
case 'fill-fetch-stream-closed': // drafts 20–22
case 'subscription-state-notified': // drafts 20–22
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