Files
pipecat/moq_prebuilt/client/moq-client.js
2026-05-15 16:59:07 -05:00

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,
};
})();