325 lines
8.9 KiB
JavaScript
325 lines
8.9 KiB
JavaScript
/*
|
|
* Copyright (c) 2024-2026, Daily
|
|
*
|
|
* SPDX-License-Identifier: BSD 2-Clause License
|
|
*/
|
|
|
|
/**
|
|
* MOQ (Media over QUIC) protocol client — moq-lite-02.
|
|
*
|
|
* Implements varint codec, message encode/decode for moq-lite-02 protocol
|
|
* so the browser can talk to a moq-lite relay over WebTransport.
|
|
*
|
|
* Key differences from draft-07:
|
|
* - Stream-per-request model (no shared control stream)
|
|
* - Setup uses u8(0x20/0x21) framing; via WebTransport uses u16 body size
|
|
* - SUBSCRIBE/ANNOUNCE each get their own bidi stream
|
|
* - Media data flows on uni streams as GROUP + FRAME
|
|
* - No QUIC datagrams
|
|
*/
|
|
(function () {
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MOQL_VERSION = 0xff0dad02; // moq-lite-02
|
|
|
|
const Role = Object.freeze({
|
|
PUBLISHER: 0x01,
|
|
SUBSCRIBER: 0x02,
|
|
PUBSUB: 0x03,
|
|
});
|
|
|
|
// Stream type varints (first thing written on a new bidi stream)
|
|
const StreamType = Object.freeze({
|
|
SESSION: 0,
|
|
ANNOUNCE: 1,
|
|
SUBSCRIBE: 2,
|
|
});
|
|
|
|
// Setup message type bytes
|
|
const CLIENT_SETUP_TYPE = 0x20;
|
|
const SERVER_SETUP_TYPE = 0x21;
|
|
|
|
// Uni stream type
|
|
const UNI_STREAM_TYPE_GROUP = 0;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// QUIC variable-length integer codec
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function encodeVarint(value) {
|
|
if (value < 0x40) {
|
|
return new Uint8Array([value]);
|
|
} else if (value < 0x4000) {
|
|
const buf = new Uint8Array(2);
|
|
new DataView(buf.buffer).setUint16(0, value | 0x4000);
|
|
return buf;
|
|
} else if (value < 0x40000000) {
|
|
const buf = new Uint8Array(4);
|
|
new DataView(buf.buffer).setUint32(0, (value | 0x80000000) >>> 0);
|
|
return buf;
|
|
} else {
|
|
const buf = new Uint8Array(8);
|
|
const dv = new DataView(buf.buffer);
|
|
const hi = Math.floor(value / 0x100000000) | 0xc0000000;
|
|
const lo = value >>> 0;
|
|
dv.setUint32(0, hi >>> 0);
|
|
dv.setUint32(4, lo);
|
|
return buf;
|
|
}
|
|
}
|
|
|
|
function decodeVarint(data, offset) {
|
|
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
const first = data[offset];
|
|
const lengthBits = first >> 6;
|
|
|
|
if (lengthBits === 0) {
|
|
return [first, offset + 1];
|
|
} else if (lengthBits === 1) {
|
|
return [dv.getUint16(offset) & 0x3fff, offset + 2];
|
|
} else if (lengthBits === 2) {
|
|
return [dv.getUint32(offset) & 0x3fffffff, offset + 4];
|
|
} else {
|
|
const hi = dv.getUint32(offset) & 0x3fffffff;
|
|
const lo = dv.getUint32(offset + 4);
|
|
return [hi * 0x100000000 + lo, offset + 8];
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// String helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const encoder = new TextEncoder();
|
|
const decoder = new TextDecoder();
|
|
|
|
function encodeString(str) {
|
|
const bytes = encoder.encode(str);
|
|
return concat(encodeVarint(bytes.length), bytes);
|
|
}
|
|
|
|
function decodeString(data, offset) {
|
|
const [len, off] = decodeVarint(data, offset);
|
|
const str = decoder.decode(data.subarray(off, off + len));
|
|
return [str, off + len];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Buffer helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function concat(...arrays) {
|
|
let total = 0;
|
|
for (const a of arrays) total += a.byteLength;
|
|
const result = new Uint8Array(total);
|
|
let pos = 0;
|
|
for (const a of arrays) {
|
|
result.set(a instanceof Uint8Array ? a : new Uint8Array(a), pos);
|
|
pos += a.byteLength;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup messages (WebTransport uses u16 body size prefix)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Encode CLIENT_SETUP for WebTransport (browser).
|
|
*
|
|
* WebTransport framing: varint(0x20) as stream type, then u16(body_len) + body
|
|
* on a dedicated bidi stream.
|
|
*/
|
|
function encodeClientSetup(role, versions, path) {
|
|
let body = encodeVarint(versions.length);
|
|
for (const v of versions) {
|
|
body = concat(body, encodeVarint(v));
|
|
}
|
|
|
|
// Parameters
|
|
let numParams = 1; // role
|
|
if (path) numParams += 1;
|
|
body = concat(body, encodeVarint(numParams));
|
|
|
|
// Role param (key=0)
|
|
body = concat(body, encodeVarint(0), encodeVarint(1), encodeVarint(role));
|
|
|
|
// Path param (key=1)
|
|
if (path) {
|
|
const pathBytes = encoder.encode(path);
|
|
body = concat(body, encodeVarint(1), encodeVarint(pathBytes.length), pathBytes);
|
|
}
|
|
|
|
// WebTransport framing: u16(body_len) + body
|
|
const frame = new Uint8Array(2 + body.length);
|
|
new DataView(frame.buffer).setUint16(0, body.length);
|
|
frame.set(body, 2);
|
|
|
|
return frame;
|
|
}
|
|
|
|
/**
|
|
* Parse SERVER_SETUP from WebTransport.
|
|
*
|
|
* WebTransport framing: u16(body_len) + body
|
|
* Body: varint(selected_version) + params...
|
|
*/
|
|
function parseServerSetup(data) {
|
|
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
const bodyLen = dv.getUint16(0);
|
|
let offset = 2;
|
|
const [version, newOff] = decodeVarint(data, offset);
|
|
return { version, bodyEnd: 2 + bodyLen };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subscribe messages (on dedicated bidi stream with stream_type=2)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Encode SUBSCRIBE message body.
|
|
*
|
|
* Stream format: varint(2) + varint(body_len) + body
|
|
* Body: varint(sub_id) + string(broadcast_path) + string(track_name) + u8(priority)
|
|
*/
|
|
function encodeSubscribe(subscribeId, broadcastPath, trackName, priority = 128) {
|
|
let body = concat(
|
|
encodeVarint(subscribeId),
|
|
encodeString(broadcastPath),
|
|
encodeString(trackName),
|
|
new Uint8Array([priority]),
|
|
);
|
|
|
|
// Stream type + length-prefixed body
|
|
return concat(
|
|
encodeVarint(StreamType.SUBSCRIBE),
|
|
encodeVarint(body.length),
|
|
body,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Encode SUBSCRIBE_OK response.
|
|
* Format: varint(0) — empty body.
|
|
*/
|
|
function encodeSubscribeOk() {
|
|
return encodeVarint(0);
|
|
}
|
|
|
|
/**
|
|
* Decode a SUBSCRIBE message from an incoming bidi stream.
|
|
* Input starts after the stream type varint has been consumed.
|
|
*/
|
|
function decodeSubscribe(data, offset = 0) {
|
|
let bodyLen;
|
|
[bodyLen, offset] = decodeVarint(data, offset);
|
|
const bodyEnd = offset + bodyLen;
|
|
|
|
let subscribeId, broadcastPath, trackName;
|
|
[subscribeId, offset] = decodeVarint(data, offset);
|
|
[broadcastPath, offset] = decodeString(data, offset);
|
|
[trackName, offset] = decodeString(data, offset);
|
|
const priority = data[offset];
|
|
offset += 1;
|
|
|
|
return { subscribeId, broadcastPath, trackName, priority, end: bodyEnd };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GROUP + FRAME messages (on unidirectional streams)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Encode GROUP header + single FRAME for a uni stream.
|
|
*
|
|
* Format: u8(0) + varint(header_body_len) + varint(subscribe_id) + varint(group_seq)
|
|
* + varint(payload_len) + payload
|
|
*/
|
|
function encodeGroupAndFrame(subscribeId, groupSeq, payload) {
|
|
const headerBody = concat(
|
|
encodeVarint(subscribeId),
|
|
encodeVarint(groupSeq),
|
|
);
|
|
|
|
const frame = concat(
|
|
encodeVarint(payload.byteLength),
|
|
payload instanceof Uint8Array ? payload : new Uint8Array(payload),
|
|
);
|
|
|
|
return concat(
|
|
new Uint8Array([UNI_STREAM_TYPE_GROUP]),
|
|
encodeVarint(headerBody.length),
|
|
headerBody,
|
|
frame,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse GROUP header + FRAMEs from a complete uni stream buffer.
|
|
*
|
|
* Returns { subscribeId, groupSeq, frames: [Uint8Array, ...] }
|
|
*/
|
|
function parseGroupStream(data) {
|
|
let offset = 0;
|
|
|
|
// u8(0) stream type
|
|
const streamType = data[offset];
|
|
offset += 1;
|
|
|
|
// varint(body_len)
|
|
let bodyLen;
|
|
[bodyLen, offset] = decodeVarint(data, offset);
|
|
const bodyStart = offset;
|
|
|
|
// varint(subscribe_id)
|
|
let subscribeId;
|
|
[subscribeId, offset] = decodeVarint(data, offset);
|
|
|
|
// varint(group_seq)
|
|
let groupSeq;
|
|
[groupSeq, offset] = decodeVarint(data, offset);
|
|
|
|
// Advance past header body
|
|
offset = bodyStart + bodyLen;
|
|
|
|
// Parse frames: varint(payload_len) + payload, repeated
|
|
const frames = [];
|
|
while (offset < data.length) {
|
|
let payloadLen;
|
|
[payloadLen, offset] = decodeVarint(data, offset);
|
|
frames.push(data.subarray(offset, offset + payloadLen));
|
|
offset += payloadLen;
|
|
}
|
|
|
|
return { subscribeId, groupSeq, frames };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Exports (global for vanilla JS)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
window.MOQ = {
|
|
MOQL_VERSION,
|
|
Role,
|
|
StreamType,
|
|
CLIENT_SETUP_TYPE,
|
|
SERVER_SETUP_TYPE,
|
|
encodeVarint,
|
|
decodeVarint,
|
|
encodeString,
|
|
decodeString,
|
|
concat,
|
|
encodeClientSetup,
|
|
parseServerSetup,
|
|
encodeSubscribe,
|
|
encodeSubscribeOk,
|
|
decodeSubscribe,
|
|
encodeGroupAndFrame,
|
|
parseGroupStream,
|
|
};
|
|
|
|
})();
|