POC: Add MoQ transport
This commit is contained in:
324
moq_prebuilt/client/moq-client.js
Normal file
324
moq_prebuilt/client/moq-client.js
Normal file
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* 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,
|
||||
};
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user