mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
✨ feat: Host a relay on Hetzner (#114)
We are hosting a [MoQ](https://quic.video) relay on a remote (bare metal) server on Hetzner With a lot of help from @victorpahuus
This commit is contained in:
83
packages/moq/transport/client.ts
Normal file
83
packages/moq/transport/client.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as Stream from "./stream"
|
||||
import * as Setup from "./setup"
|
||||
import * as Control from "./control"
|
||||
import { Objects } from "./objects"
|
||||
import { Connection } from "./connection"
|
||||
|
||||
export interface ClientConfig {
|
||||
url: string
|
||||
|
||||
// Parameters used to create the MoQ session
|
||||
role: Setup.Role
|
||||
|
||||
// If set, the server fingerprint will be fetched from this URL.
|
||||
// This is required to use self-signed certificates with Chrome (May 2023)
|
||||
fingerprint?: string
|
||||
}
|
||||
|
||||
export class Client {
|
||||
#fingerprint: Promise<WebTransportHash | undefined>
|
||||
|
||||
readonly config: ClientConfig
|
||||
|
||||
constructor(config: ClientConfig) {
|
||||
this.config = config
|
||||
|
||||
this.#fingerprint = this.#fetchFingerprint(config.fingerprint).catch((e) => {
|
||||
console.warn("failed to fetch fingerprint: ", e)
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
async connect(): Promise<Connection> {
|
||||
// Helper function to make creating a promise easier
|
||||
const options: WebTransportOptions = {}
|
||||
|
||||
const fingerprint = await this.#fingerprint
|
||||
if (fingerprint) options.serverCertificateHashes = [fingerprint]
|
||||
|
||||
const quic = new WebTransport(this.config.url, options)
|
||||
await quic.ready
|
||||
|
||||
const stream = await quic.createBidirectionalStream()
|
||||
|
||||
const writer = new Stream.Writer(stream.writable)
|
||||
const reader = new Stream.Reader(new Uint8Array(), stream.readable)
|
||||
|
||||
const setup = new Setup.Stream(reader, writer)
|
||||
|
||||
// Send the setup message.
|
||||
await setup.send.client({ versions: [Setup.Version.DRAFT_04], role: this.config.role })
|
||||
|
||||
// Receive the setup message.
|
||||
// TODO verify the SETUP response.
|
||||
const server = await setup.recv.server()
|
||||
|
||||
if (server.version != Setup.Version.DRAFT_04) {
|
||||
throw new Error(`unsupported server version: ${server.version}`)
|
||||
}
|
||||
|
||||
const control = new Control.Stream(reader, writer)
|
||||
const objects = new Objects(quic)
|
||||
|
||||
return new Connection(quic, control, objects)
|
||||
}
|
||||
|
||||
async #fetchFingerprint(url?: string): Promise<WebTransportHash | undefined> {
|
||||
if (!url) return
|
||||
|
||||
// TODO remove this fingerprint when Chrome WebTransport accepts the system CA
|
||||
const response = await fetch(url)
|
||||
const hexString = await response.text()
|
||||
|
||||
const hexBytes = new Uint8Array(hexString.length / 2)
|
||||
for (let i = 0; i < hexBytes.length; i += 1) {
|
||||
hexBytes[i] = parseInt(hexString.slice(2 * i, 2 * i + 2), 16)
|
||||
}
|
||||
|
||||
return {
|
||||
algorithm: "sha-256",
|
||||
value: hexBytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
95
packages/moq/transport/connection.ts
Normal file
95
packages/moq/transport/connection.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as Control from "./control"
|
||||
import { Objects } from "./objects"
|
||||
import { asError } from "../common/error"
|
||||
|
||||
import { Publisher } from "./publisher"
|
||||
import { Subscriber } from "./subscriber"
|
||||
|
||||
export class Connection {
|
||||
// The established WebTransport session.
|
||||
#quic: WebTransport
|
||||
|
||||
// Use to receive/send control messages.
|
||||
#control: Control.Stream
|
||||
|
||||
// Use to receive/send objects.
|
||||
#objects: Objects
|
||||
|
||||
// Module for contributing tracks.
|
||||
#publisher: Publisher
|
||||
|
||||
// Module for distributing tracks.
|
||||
#subscriber: Subscriber
|
||||
|
||||
// Async work running in the background
|
||||
#running: Promise<void>
|
||||
|
||||
constructor(quic: WebTransport, control: Control.Stream, objects: Objects) {
|
||||
this.#quic = quic
|
||||
this.#control = control
|
||||
this.#objects = objects
|
||||
|
||||
this.#publisher = new Publisher(this.#control, this.#objects)
|
||||
this.#subscriber = new Subscriber(this.#control, this.#objects)
|
||||
|
||||
this.#running = this.#run()
|
||||
}
|
||||
|
||||
close(code = 0, reason = "") {
|
||||
this.#quic.close({ closeCode: code, reason })
|
||||
}
|
||||
|
||||
async #run(): Promise<void> {
|
||||
await Promise.all([this.#runControl(), this.#runObjects()])
|
||||
}
|
||||
|
||||
announce(namespace: string) {
|
||||
return this.#publisher.announce(namespace)
|
||||
}
|
||||
|
||||
announced() {
|
||||
return this.#subscriber.announced()
|
||||
}
|
||||
|
||||
subscribe(namespace: string, track: string) {
|
||||
return this.#subscriber.subscribe(namespace, track)
|
||||
}
|
||||
|
||||
subscribed() {
|
||||
return this.#publisher.subscribed()
|
||||
}
|
||||
|
||||
async #runControl() {
|
||||
// Receive messages until the connection is closed.
|
||||
for (;;) {
|
||||
const msg = await this.#control.recv()
|
||||
await this.#recv(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async #runObjects() {
|
||||
for (;;) {
|
||||
const obj = await this.#objects.recv()
|
||||
if (!obj) break
|
||||
|
||||
await this.#subscriber.recvObject(obj)
|
||||
}
|
||||
}
|
||||
|
||||
async #recv(msg: Control.Message) {
|
||||
if (Control.isPublisher(msg)) {
|
||||
await this.#subscriber.recv(msg)
|
||||
} else {
|
||||
await this.#publisher.recv(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async closed(): Promise<Error> {
|
||||
try {
|
||||
await this.#running
|
||||
return new Error("closed")
|
||||
} catch (e) {
|
||||
return asError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
550
packages/moq/transport/control.ts
Normal file
550
packages/moq/transport/control.ts
Normal file
@@ -0,0 +1,550 @@
|
||||
import { Reader, Writer } from "./stream"
|
||||
|
||||
export type Message = Subscriber | Publisher
|
||||
|
||||
// Sent by subscriber
|
||||
export type Subscriber = Subscribe | Unsubscribe | AnnounceOk | AnnounceError
|
||||
|
||||
export function isSubscriber(m: Message): m is Subscriber {
|
||||
return (
|
||||
m.kind == Msg.Subscribe || m.kind == Msg.Unsubscribe || m.kind == Msg.AnnounceOk || m.kind == Msg.AnnounceError
|
||||
)
|
||||
}
|
||||
|
||||
// Sent by publisher
|
||||
export type Publisher = SubscribeOk | SubscribeError | SubscribeDone | Announce | Unannounce
|
||||
|
||||
export function isPublisher(m: Message): m is Publisher {
|
||||
return (
|
||||
m.kind == Msg.SubscribeOk ||
|
||||
m.kind == Msg.SubscribeError ||
|
||||
m.kind == Msg.SubscribeDone ||
|
||||
m.kind == Msg.Announce ||
|
||||
m.kind == Msg.Unannounce
|
||||
)
|
||||
}
|
||||
|
||||
// I wish we didn't have to split Msg and Id into separate enums.
|
||||
// However using the string in the message makes it easier to debug.
|
||||
// We'll take the tiny performance hit until I'm better at Typescript.
|
||||
export enum Msg {
|
||||
// NOTE: object and setup are in other modules
|
||||
Subscribe = "subscribe",
|
||||
SubscribeOk = "subscribe_ok",
|
||||
SubscribeError = "subscribe_error",
|
||||
SubscribeDone = "subscribe_done",
|
||||
Unsubscribe = "unsubscribe",
|
||||
Announce = "announce",
|
||||
AnnounceOk = "announce_ok",
|
||||
AnnounceError = "announce_error",
|
||||
Unannounce = "unannounce",
|
||||
GoAway = "go_away",
|
||||
}
|
||||
|
||||
enum Id {
|
||||
// NOTE: object and setup are in other modules
|
||||
// Object = 0,
|
||||
// Setup = 1,
|
||||
|
||||
Subscribe = 0x3,
|
||||
SubscribeOk = 0x4,
|
||||
SubscribeError = 0x5,
|
||||
SubscribeDone = 0xb,
|
||||
Unsubscribe = 0xa,
|
||||
Announce = 0x6,
|
||||
AnnounceOk = 0x7,
|
||||
AnnounceError = 0x8,
|
||||
Unannounce = 0x9,
|
||||
GoAway = 0x10,
|
||||
}
|
||||
|
||||
export interface Subscribe {
|
||||
kind: Msg.Subscribe
|
||||
|
||||
id: bigint
|
||||
trackId: bigint
|
||||
namespace: string
|
||||
name: string
|
||||
|
||||
location: Location
|
||||
|
||||
params?: Parameters
|
||||
}
|
||||
|
||||
export type Location = LatestGroup | LatestObject | AbsoluteStart | AbsoluteRange
|
||||
|
||||
export interface LatestGroup {
|
||||
mode: "latest_group"
|
||||
}
|
||||
|
||||
export interface LatestObject {
|
||||
mode: "latest_object"
|
||||
}
|
||||
|
||||
export interface AbsoluteStart {
|
||||
mode: "absolute_start"
|
||||
start_group: number
|
||||
start_object: number
|
||||
}
|
||||
|
||||
export interface AbsoluteRange {
|
||||
mode: "absolute_range"
|
||||
start_group: number
|
||||
start_object: number
|
||||
end_group: number
|
||||
end_object: number
|
||||
}
|
||||
|
||||
export type Parameters = Map<bigint, Uint8Array>
|
||||
|
||||
export interface SubscribeOk {
|
||||
kind: Msg.SubscribeOk
|
||||
id: bigint
|
||||
expires: bigint
|
||||
latest?: [number, number]
|
||||
}
|
||||
|
||||
export interface SubscribeDone {
|
||||
kind: Msg.SubscribeDone
|
||||
id: bigint
|
||||
code: bigint
|
||||
reason: string
|
||||
final?: [number, number]
|
||||
}
|
||||
|
||||
export interface SubscribeError {
|
||||
kind: Msg.SubscribeError
|
||||
id: bigint
|
||||
code: bigint
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface Unsubscribe {
|
||||
kind: Msg.Unsubscribe
|
||||
id: bigint
|
||||
}
|
||||
|
||||
export interface Announce {
|
||||
kind: Msg.Announce
|
||||
namespace: string
|
||||
params?: Parameters
|
||||
}
|
||||
|
||||
export interface AnnounceOk {
|
||||
kind: Msg.AnnounceOk
|
||||
namespace: string
|
||||
}
|
||||
|
||||
export interface AnnounceError {
|
||||
kind: Msg.AnnounceError
|
||||
namespace: string
|
||||
code: bigint
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface Unannounce {
|
||||
kind: Msg.Unannounce
|
||||
namespace: string
|
||||
}
|
||||
|
||||
export class Stream {
|
||||
private decoder: Decoder
|
||||
private encoder: Encoder
|
||||
|
||||
#mutex = Promise.resolve()
|
||||
|
||||
constructor(r: Reader, w: Writer) {
|
||||
this.decoder = new Decoder(r)
|
||||
this.encoder = new Encoder(w)
|
||||
}
|
||||
|
||||
// Will error if two messages are read at once.
|
||||
async recv(): Promise<Message> {
|
||||
const msg = await this.decoder.message()
|
||||
console.log("received message", msg)
|
||||
return msg
|
||||
}
|
||||
|
||||
async send(msg: Message) {
|
||||
const unlock = await this.#lock()
|
||||
try {
|
||||
console.log("sending message", msg)
|
||||
await this.encoder.message(msg)
|
||||
} finally {
|
||||
unlock()
|
||||
}
|
||||
}
|
||||
|
||||
async #lock() {
|
||||
// Make a new promise that we can resolve later.
|
||||
let done: () => void
|
||||
const p = new Promise<void>((resolve) => {
|
||||
done = () => resolve()
|
||||
})
|
||||
|
||||
// Wait until the previous lock is done, then resolve our our lock.
|
||||
const lock = this.#mutex.then(() => done)
|
||||
|
||||
// Save our lock as the next lock.
|
||||
this.#mutex = p
|
||||
|
||||
// Return the lock.
|
||||
return lock
|
||||
}
|
||||
}
|
||||
|
||||
export class Decoder {
|
||||
r: Reader
|
||||
|
||||
constructor(r: Reader) {
|
||||
this.r = r
|
||||
}
|
||||
|
||||
private async msg(): Promise<Msg> {
|
||||
const t = await this.r.u53()
|
||||
switch (t) {
|
||||
case Id.Subscribe:
|
||||
return Msg.Subscribe
|
||||
case Id.SubscribeOk:
|
||||
return Msg.SubscribeOk
|
||||
case Id.SubscribeDone:
|
||||
return Msg.SubscribeDone
|
||||
case Id.SubscribeError:
|
||||
return Msg.SubscribeError
|
||||
case Id.Unsubscribe:
|
||||
return Msg.Unsubscribe
|
||||
case Id.Announce:
|
||||
return Msg.Announce
|
||||
case Id.AnnounceOk:
|
||||
return Msg.AnnounceOk
|
||||
case Id.AnnounceError:
|
||||
return Msg.AnnounceError
|
||||
case Id.Unannounce:
|
||||
return Msg.Unannounce
|
||||
case Id.GoAway:
|
||||
return Msg.GoAway
|
||||
}
|
||||
|
||||
throw new Error(`unknown control message type: ${t}`)
|
||||
}
|
||||
|
||||
async message(): Promise<Message> {
|
||||
const t = await this.msg()
|
||||
switch (t) {
|
||||
case Msg.Subscribe:
|
||||
return this.subscribe()
|
||||
case Msg.SubscribeOk:
|
||||
return this.subscribe_ok()
|
||||
case Msg.SubscribeError:
|
||||
return this.subscribe_error()
|
||||
case Msg.SubscribeDone:
|
||||
return this.subscribe_done()
|
||||
case Msg.Unsubscribe:
|
||||
return this.unsubscribe()
|
||||
case Msg.Announce:
|
||||
return this.announce()
|
||||
case Msg.AnnounceOk:
|
||||
return this.announce_ok()
|
||||
case Msg.Unannounce:
|
||||
return this.unannounce()
|
||||
case Msg.AnnounceError:
|
||||
return this.announce_error()
|
||||
case Msg.GoAway:
|
||||
throw new Error("TODO: implement go away")
|
||||
}
|
||||
}
|
||||
|
||||
private async subscribe(): Promise<Subscribe> {
|
||||
return {
|
||||
kind: Msg.Subscribe,
|
||||
id: await this.r.u62(),
|
||||
trackId: await this.r.u62(),
|
||||
namespace: await this.r.string(),
|
||||
name: await this.r.string(),
|
||||
location: await this.location(),
|
||||
params: await this.parameters(),
|
||||
}
|
||||
}
|
||||
|
||||
private async location(): Promise<Location> {
|
||||
const mode = await this.r.u62()
|
||||
if (mode == 1n) {
|
||||
return {
|
||||
mode: "latest_group",
|
||||
}
|
||||
} else if (mode == 2n) {
|
||||
return {
|
||||
mode: "latest_object",
|
||||
}
|
||||
} else if (mode == 3n) {
|
||||
return {
|
||||
mode: "absolute_start",
|
||||
start_group: await this.r.u53(),
|
||||
start_object: await this.r.u53(),
|
||||
}
|
||||
} else if (mode == 4n) {
|
||||
return {
|
||||
mode: "absolute_range",
|
||||
start_group: await this.r.u53(),
|
||||
start_object: await this.r.u53(),
|
||||
end_group: await this.r.u53(),
|
||||
end_object: await this.r.u53(),
|
||||
}
|
||||
} else {
|
||||
throw new Error(`invalid filter type: ${mode}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async parameters(): Promise<Parameters | undefined> {
|
||||
const count = await this.r.u53()
|
||||
if (count == 0) return undefined
|
||||
|
||||
const params = new Map<bigint, Uint8Array>()
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = await this.r.u62()
|
||||
const size = await this.r.u53()
|
||||
const value = await this.r.read(size)
|
||||
|
||||
if (params.has(id)) {
|
||||
throw new Error(`duplicate parameter id: ${id}`)
|
||||
}
|
||||
|
||||
params.set(id, value)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
private async subscribe_ok(): Promise<SubscribeOk> {
|
||||
const id = await this.r.u62()
|
||||
const expires = await this.r.u62()
|
||||
|
||||
let latest: [number, number] | undefined
|
||||
|
||||
const flag = await this.r.u8()
|
||||
if (flag === 1) {
|
||||
latest = [await this.r.u53(), await this.r.u53()]
|
||||
} else if (flag !== 0) {
|
||||
throw new Error(`invalid final flag: ${flag}`)
|
||||
}
|
||||
|
||||
return {
|
||||
kind: Msg.SubscribeOk,
|
||||
id,
|
||||
expires,
|
||||
latest,
|
||||
}
|
||||
}
|
||||
|
||||
private async subscribe_done(): Promise<SubscribeDone> {
|
||||
const id = await this.r.u62()
|
||||
const code = await this.r.u62()
|
||||
const reason = await this.r.string()
|
||||
|
||||
let final: [number, number] | undefined
|
||||
|
||||
const flag = await this.r.u8()
|
||||
if (flag === 1) {
|
||||
final = [await this.r.u53(), await this.r.u53()]
|
||||
} else if (flag !== 0) {
|
||||
throw new Error(`invalid final flag: ${flag}`)
|
||||
}
|
||||
|
||||
return {
|
||||
kind: Msg.SubscribeDone,
|
||||
id,
|
||||
code,
|
||||
reason,
|
||||
final,
|
||||
}
|
||||
}
|
||||
|
||||
private async subscribe_error(): Promise<SubscribeError> {
|
||||
return {
|
||||
kind: Msg.SubscribeError,
|
||||
id: await this.r.u62(),
|
||||
code: await this.r.u62(),
|
||||
reason: await this.r.string(),
|
||||
}
|
||||
}
|
||||
|
||||
private async unsubscribe(): Promise<Unsubscribe> {
|
||||
return {
|
||||
kind: Msg.Unsubscribe,
|
||||
id: await this.r.u62(),
|
||||
}
|
||||
}
|
||||
|
||||
private async announce(): Promise<Announce> {
|
||||
const namespace = await this.r.string()
|
||||
|
||||
return {
|
||||
kind: Msg.Announce,
|
||||
namespace,
|
||||
params: await this.parameters(),
|
||||
}
|
||||
}
|
||||
|
||||
private async announce_ok(): Promise<AnnounceOk> {
|
||||
return {
|
||||
kind: Msg.AnnounceOk,
|
||||
namespace: await this.r.string(),
|
||||
}
|
||||
}
|
||||
|
||||
private async announce_error(): Promise<AnnounceError> {
|
||||
return {
|
||||
kind: Msg.AnnounceError,
|
||||
namespace: await this.r.string(),
|
||||
code: await this.r.u62(),
|
||||
reason: await this.r.string(),
|
||||
}
|
||||
}
|
||||
|
||||
private async unannounce(): Promise<Unannounce> {
|
||||
return {
|
||||
kind: Msg.Unannounce,
|
||||
namespace: await this.r.string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Encoder {
|
||||
w: Writer
|
||||
|
||||
constructor(w: Writer) {
|
||||
this.w = w
|
||||
}
|
||||
|
||||
async message(m: Message) {
|
||||
switch (m.kind) {
|
||||
case Msg.Subscribe:
|
||||
return this.subscribe(m)
|
||||
case Msg.SubscribeOk:
|
||||
return this.subscribe_ok(m)
|
||||
case Msg.SubscribeError:
|
||||
return this.subscribe_error(m)
|
||||
case Msg.SubscribeDone:
|
||||
return this.subscribe_done(m)
|
||||
case Msg.Unsubscribe:
|
||||
return this.unsubscribe(m)
|
||||
case Msg.Announce:
|
||||
return this.announce(m)
|
||||
case Msg.AnnounceOk:
|
||||
return this.announce_ok(m)
|
||||
case Msg.AnnounceError:
|
||||
return this.announce_error(m)
|
||||
case Msg.Unannounce:
|
||||
return this.unannounce(m)
|
||||
}
|
||||
}
|
||||
|
||||
async subscribe(s: Subscribe) {
|
||||
await this.w.u53(Id.Subscribe)
|
||||
await this.w.u62(s.id)
|
||||
await this.w.u62(s.trackId)
|
||||
await this.w.string(s.namespace)
|
||||
await this.w.string(s.name)
|
||||
await this.location(s.location)
|
||||
await this.parameters(s.params)
|
||||
}
|
||||
|
||||
private async location(l: Location) {
|
||||
switch (l.mode) {
|
||||
case "latest_group":
|
||||
await this.w.u62(1n)
|
||||
break
|
||||
case "latest_object":
|
||||
await this.w.u62(2n)
|
||||
break
|
||||
case "absolute_start":
|
||||
await this.w.u62(3n)
|
||||
await this.w.u53(l.start_group)
|
||||
await this.w.u53(l.start_object)
|
||||
break
|
||||
case "absolute_range":
|
||||
await this.w.u62(3n)
|
||||
await this.w.u53(l.start_group)
|
||||
await this.w.u53(l.start_object)
|
||||
await this.w.u53(l.end_group)
|
||||
await this.w.u53(l.end_object)
|
||||
}
|
||||
}
|
||||
|
||||
private async parameters(p: Parameters | undefined) {
|
||||
if (!p) {
|
||||
await this.w.u8(0)
|
||||
return
|
||||
}
|
||||
|
||||
await this.w.u53(p.size)
|
||||
for (const [id, value] of p) {
|
||||
await this.w.u62(id)
|
||||
await this.w.u53(value.length)
|
||||
await this.w.write(value)
|
||||
}
|
||||
}
|
||||
|
||||
async subscribe_ok(s: SubscribeOk) {
|
||||
await this.w.u53(Id.SubscribeOk)
|
||||
await this.w.u62(s.id)
|
||||
await this.w.u62(s.expires)
|
||||
|
||||
if (s.latest !== undefined) {
|
||||
await this.w.u8(1)
|
||||
await this.w.u53(s.latest[0])
|
||||
await this.w.u53(s.latest[1])
|
||||
} else {
|
||||
await this.w.u8(0)
|
||||
}
|
||||
}
|
||||
|
||||
async subscribe_done(s: SubscribeDone) {
|
||||
await this.w.u53(Id.SubscribeDone)
|
||||
await this.w.u62(s.id)
|
||||
await this.w.u62(s.code)
|
||||
await this.w.string(s.reason)
|
||||
|
||||
if (s.final !== undefined) {
|
||||
await this.w.u8(1)
|
||||
await this.w.u53(s.final[0])
|
||||
await this.w.u53(s.final[1])
|
||||
} else {
|
||||
await this.w.u8(0)
|
||||
}
|
||||
}
|
||||
|
||||
async subscribe_error(s: SubscribeError) {
|
||||
await this.w.u53(Id.SubscribeError)
|
||||
await this.w.u62(s.id)
|
||||
}
|
||||
|
||||
async unsubscribe(s: Unsubscribe) {
|
||||
await this.w.u53(Id.Unsubscribe)
|
||||
await this.w.u62(s.id)
|
||||
}
|
||||
|
||||
async announce(a: Announce) {
|
||||
await this.w.u53(Id.Announce)
|
||||
await this.w.string(a.namespace)
|
||||
await this.w.u53(0) // parameters
|
||||
}
|
||||
|
||||
async announce_ok(a: AnnounceOk) {
|
||||
await this.w.u53(Id.AnnounceOk)
|
||||
await this.w.string(a.namespace)
|
||||
}
|
||||
|
||||
async announce_error(a: AnnounceError) {
|
||||
await this.w.u53(Id.AnnounceError)
|
||||
await this.w.string(a.namespace)
|
||||
await this.w.u62(a.code)
|
||||
await this.w.string(a.reason)
|
||||
}
|
||||
|
||||
async unannounce(a: Unannounce) {
|
||||
await this.w.u53(Id.Unannounce)
|
||||
await this.w.string(a.namespace)
|
||||
}
|
||||
}
|
||||
7
packages/moq/transport/index.ts
Normal file
7
packages/moq/transport/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { Client } from "./client"
|
||||
export type { ClientConfig } from "./client"
|
||||
|
||||
export { Connection } from "./connection"
|
||||
|
||||
export { SubscribeRecv, AnnounceSend } from "./publisher"
|
||||
export { AnnounceRecv, SubscribeSend } from "./subscriber"
|
||||
307
packages/moq/transport/objects.ts
Normal file
307
packages/moq/transport/objects.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { Reader, Writer } from "./stream"
|
||||
export { Reader, Writer }
|
||||
|
||||
export enum StreamType {
|
||||
Object = 0x0,
|
||||
Track = 0x50,
|
||||
Group = 0x51,
|
||||
}
|
||||
|
||||
export enum Status {
|
||||
OBJECT_NULL = 1,
|
||||
GROUP_NULL = 2,
|
||||
GROUP_END = 3,
|
||||
TRACK_END = 4,
|
||||
}
|
||||
|
||||
export interface TrackHeader {
|
||||
type: StreamType.Track
|
||||
sub: bigint
|
||||
track: bigint
|
||||
priority: number // VarInt with a u32 maximum value
|
||||
}
|
||||
|
||||
export interface TrackChunk {
|
||||
group: number // The group sequence, as a number because 2^53 is enough.
|
||||
object: number
|
||||
payload: Uint8Array | Status
|
||||
}
|
||||
|
||||
export interface GroupHeader {
|
||||
type: StreamType.Group
|
||||
sub: bigint
|
||||
track: bigint
|
||||
group: number // The group sequence, as a number because 2^53 is enough.
|
||||
priority: number // VarInt with a u32 maximum value
|
||||
}
|
||||
|
||||
export interface GroupChunk {
|
||||
object: number
|
||||
payload: Uint8Array | Status
|
||||
}
|
||||
|
||||
export interface ObjectHeader {
|
||||
type: StreamType.Object
|
||||
sub: bigint
|
||||
track: bigint
|
||||
group: number
|
||||
object: number
|
||||
priority: number
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface ObjectChunk {
|
||||
payload: Uint8Array
|
||||
}
|
||||
|
||||
type WriterType<T> = T extends TrackHeader
|
||||
? TrackWriter
|
||||
: T extends GroupHeader
|
||||
? GroupWriter
|
||||
: T extends ObjectHeader
|
||||
? ObjectWriter
|
||||
: never
|
||||
|
||||
export class Objects {
|
||||
private quic: WebTransport
|
||||
|
||||
constructor(quic: WebTransport) {
|
||||
this.quic = quic
|
||||
}
|
||||
|
||||
async send<T extends TrackHeader | GroupHeader | ObjectHeader>(h: T): Promise<WriterType<T>> {
|
||||
const stream = await this.quic.createUnidirectionalStream()
|
||||
const w = new Writer(stream)
|
||||
|
||||
await w.u53(h.type)
|
||||
await w.u62(h.sub)
|
||||
await w.u62(h.track)
|
||||
|
||||
let res: WriterType<T>
|
||||
|
||||
if (h.type == StreamType.Object) {
|
||||
await w.u53(h.group)
|
||||
await w.u53(h.object)
|
||||
await w.u53(h.priority)
|
||||
await w.u53(h.status)
|
||||
|
||||
res = new ObjectWriter(h, w) as WriterType<T>
|
||||
} else if (h.type === StreamType.Group) {
|
||||
await w.u53(h.group)
|
||||
await w.u53(h.priority)
|
||||
|
||||
res = new GroupWriter(h, w) as WriterType<T>
|
||||
} else if (h.type === StreamType.Track) {
|
||||
await w.u53(h.priority)
|
||||
|
||||
res = new TrackWriter(h, w) as WriterType<T>
|
||||
} else {
|
||||
throw new Error("unknown header type")
|
||||
}
|
||||
|
||||
// console.trace("send object", res.header)
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
async recv(): Promise<TrackReader | GroupReader | ObjectReader | undefined> {
|
||||
const streams = this.quic.incomingUnidirectionalStreams.getReader()
|
||||
|
||||
const { value, done } = await streams.read()
|
||||
streams.releaseLock()
|
||||
|
||||
if (done) return
|
||||
|
||||
const r = new Reader(new Uint8Array(), value)
|
||||
const type = (await r.u53()) as StreamType
|
||||
let res: TrackReader | GroupReader | ObjectReader
|
||||
|
||||
if (type == StreamType.Track) {
|
||||
const h: TrackHeader = {
|
||||
type,
|
||||
sub: await r.u62(),
|
||||
track: await r.u62(),
|
||||
priority: await r.u53(),
|
||||
}
|
||||
|
||||
res = new TrackReader(h, r)
|
||||
} else if (type == StreamType.Group) {
|
||||
const h: GroupHeader = {
|
||||
type,
|
||||
sub: await r.u62(),
|
||||
track: await r.u62(),
|
||||
group: await r.u53(),
|
||||
priority: await r.u53(),
|
||||
}
|
||||
res = new GroupReader(h, r)
|
||||
} else if (type == StreamType.Object) {
|
||||
const h = {
|
||||
type,
|
||||
sub: await r.u62(),
|
||||
track: await r.u62(),
|
||||
group: await r.u53(),
|
||||
object: await r.u53(),
|
||||
status: await r.u53(),
|
||||
priority: await r.u53(),
|
||||
}
|
||||
|
||||
res = new ObjectReader(h, r)
|
||||
} else {
|
||||
throw new Error("unknown stream type")
|
||||
}
|
||||
|
||||
// console.trace("receive object", res.header)
|
||||
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
export class TrackWriter {
|
||||
constructor(
|
||||
public header: TrackHeader,
|
||||
public stream: Writer,
|
||||
) {}
|
||||
|
||||
async write(c: TrackChunk) {
|
||||
await this.stream.u53(c.group)
|
||||
await this.stream.u53(c.object)
|
||||
|
||||
if (c.payload instanceof Uint8Array) {
|
||||
await this.stream.u53(c.payload.byteLength)
|
||||
await this.stream.write(c.payload)
|
||||
} else {
|
||||
// empty payload with status
|
||||
await this.stream.u53(0)
|
||||
await this.stream.u53(c.payload as number)
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.stream.close()
|
||||
}
|
||||
}
|
||||
|
||||
export class GroupWriter {
|
||||
constructor(
|
||||
public header: GroupHeader,
|
||||
public stream: Writer,
|
||||
) {}
|
||||
|
||||
async write(c: GroupChunk) {
|
||||
await this.stream.u53(c.object)
|
||||
if (c.payload instanceof Uint8Array) {
|
||||
await this.stream.u53(c.payload.byteLength)
|
||||
await this.stream.write(c.payload)
|
||||
} else {
|
||||
await this.stream.u53(0)
|
||||
await this.stream.u53(c.payload as number)
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.stream.close()
|
||||
}
|
||||
}
|
||||
|
||||
export class ObjectWriter {
|
||||
constructor(
|
||||
public header: ObjectHeader,
|
||||
public stream: Writer,
|
||||
) {}
|
||||
|
||||
async write(c: ObjectChunk) {
|
||||
await this.stream.write(c.payload)
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.stream.close()
|
||||
}
|
||||
}
|
||||
|
||||
export class TrackReader {
|
||||
constructor(
|
||||
public header: TrackHeader,
|
||||
public stream: Reader,
|
||||
) {}
|
||||
|
||||
async read(): Promise<TrackChunk | undefined> {
|
||||
if (await this.stream.done()) {
|
||||
return
|
||||
}
|
||||
|
||||
const group = await this.stream.u53()
|
||||
const object = await this.stream.u53()
|
||||
const size = await this.stream.u53()
|
||||
|
||||
let payload
|
||||
if (size == 0) {
|
||||
payload = (await this.stream.u53()) as Status
|
||||
} else {
|
||||
payload = await this.stream.read(size)
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
object,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.stream.close()
|
||||
}
|
||||
}
|
||||
|
||||
export class GroupReader {
|
||||
constructor(
|
||||
public header: GroupHeader,
|
||||
public stream: Reader,
|
||||
) {}
|
||||
|
||||
async read(): Promise<GroupChunk | undefined> {
|
||||
if (await this.stream.done()) {
|
||||
return
|
||||
}
|
||||
|
||||
const object = await this.stream.u53()
|
||||
const size = await this.stream.u53()
|
||||
|
||||
let payload
|
||||
if (size == 0) {
|
||||
payload = (await this.stream.u53()) as Status
|
||||
} else {
|
||||
payload = await this.stream.read(size)
|
||||
}
|
||||
|
||||
return {
|
||||
object,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.stream.close()
|
||||
}
|
||||
}
|
||||
|
||||
export class ObjectReader {
|
||||
constructor(
|
||||
public header: ObjectHeader,
|
||||
public stream: Reader,
|
||||
) {}
|
||||
|
||||
// NOTE: Can only be called once.
|
||||
async read(): Promise<ObjectChunk | undefined> {
|
||||
if (await this.stream.done()) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
payload: await this.stream.readAll(),
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.stream.close()
|
||||
}
|
||||
}
|
||||
230
packages/moq/transport/publisher.ts
Normal file
230
packages/moq/transport/publisher.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import * as Control from "./control"
|
||||
import { Queue, Watch } from "../common/async"
|
||||
import { Objects, GroupWriter, ObjectWriter, StreamType, TrackWriter } from "./objects"
|
||||
|
||||
export class Publisher {
|
||||
// Used to send control messages
|
||||
#control: Control.Stream
|
||||
|
||||
// Use to send objects.
|
||||
#objects: Objects
|
||||
|
||||
// Our announced tracks.
|
||||
#announce = new Map<string, AnnounceSend>()
|
||||
|
||||
// Their subscribed tracks.
|
||||
#subscribe = new Map<bigint, SubscribeRecv>()
|
||||
#subscribeQueue = new Queue<SubscribeRecv>(Number.MAX_SAFE_INTEGER) // Unbounded queue in case there's no receiver
|
||||
|
||||
constructor(control: Control.Stream, objects: Objects) {
|
||||
this.#control = control
|
||||
this.#objects = objects
|
||||
}
|
||||
|
||||
// Announce a track namespace.
|
||||
async announce(namespace: string): Promise<AnnounceSend> {
|
||||
if (this.#announce.has(namespace)) {
|
||||
throw new Error(`already announce: ${namespace}`)
|
||||
}
|
||||
|
||||
const announce = new AnnounceSend(this.#control, namespace)
|
||||
this.#announce.set(namespace, announce)
|
||||
|
||||
await this.#control.send({
|
||||
kind: Control.Msg.Announce,
|
||||
namespace,
|
||||
})
|
||||
|
||||
return announce
|
||||
}
|
||||
|
||||
// Receive the next new subscription
|
||||
async subscribed() {
|
||||
return await this.#subscribeQueue.next()
|
||||
}
|
||||
|
||||
async recv(msg: Control.Subscriber) {
|
||||
if (msg.kind == Control.Msg.Subscribe) {
|
||||
await this.recvSubscribe(msg)
|
||||
} else if (msg.kind == Control.Msg.Unsubscribe) {
|
||||
this.recvUnsubscribe(msg)
|
||||
} else if (msg.kind == Control.Msg.AnnounceOk) {
|
||||
this.recvAnnounceOk(msg)
|
||||
} else if (msg.kind == Control.Msg.AnnounceError) {
|
||||
this.recvAnnounceError(msg)
|
||||
} else {
|
||||
throw new Error(`unknown control message`) // impossible
|
||||
}
|
||||
}
|
||||
|
||||
recvAnnounceOk(msg: Control.AnnounceOk) {
|
||||
const announce = this.#announce.get(msg.namespace)
|
||||
if (!announce) {
|
||||
throw new Error(`announce OK for unknown announce: ${msg.namespace}`)
|
||||
}
|
||||
|
||||
announce.onOk()
|
||||
}
|
||||
|
||||
recvAnnounceError(msg: Control.AnnounceError) {
|
||||
const announce = this.#announce.get(msg.namespace)
|
||||
if (!announce) {
|
||||
// TODO debug this
|
||||
console.warn(`announce error for unknown announce: ${msg.namespace}`)
|
||||
return
|
||||
}
|
||||
|
||||
announce.onError(msg.code, msg.reason)
|
||||
}
|
||||
|
||||
async recvSubscribe(msg: Control.Subscribe) {
|
||||
if (this.#subscribe.has(msg.id)) {
|
||||
throw new Error(`duplicate subscribe for id: ${msg.id}`)
|
||||
}
|
||||
|
||||
const subscribe = new SubscribeRecv(this.#control, this.#objects, msg)
|
||||
this.#subscribe.set(msg.id, subscribe)
|
||||
await this.#subscribeQueue.push(subscribe)
|
||||
|
||||
await this.#control.send({ kind: Control.Msg.SubscribeOk, id: msg.id, expires: 0n })
|
||||
}
|
||||
|
||||
recvUnsubscribe(_msg: Control.Unsubscribe) {
|
||||
throw new Error("TODO unsubscribe")
|
||||
}
|
||||
}
|
||||
|
||||
export class AnnounceSend {
|
||||
#control: Control.Stream
|
||||
|
||||
readonly namespace: string
|
||||
|
||||
// The current state, updated by control messages.
|
||||
#state = new Watch<"init" | "ack" | Error>("init")
|
||||
|
||||
constructor(control: Control.Stream, namespace: string) {
|
||||
this.#control = control
|
||||
this.namespace = namespace
|
||||
}
|
||||
|
||||
async ok() {
|
||||
for (;;) {
|
||||
const [state, next] = this.#state.value()
|
||||
if (state === "ack") return
|
||||
if (state instanceof Error) throw state
|
||||
if (!next) throw new Error("closed")
|
||||
|
||||
await next
|
||||
}
|
||||
}
|
||||
|
||||
async active() {
|
||||
for (;;) {
|
||||
const [state, next] = this.#state.value()
|
||||
if (state instanceof Error) throw state
|
||||
if (!next) return
|
||||
|
||||
await next
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
// TODO implement unsubscribe
|
||||
// await this.#inner.sendUnsubscribe()
|
||||
}
|
||||
|
||||
closed() {
|
||||
const [state, next] = this.#state.value()
|
||||
return state instanceof Error || next == undefined
|
||||
}
|
||||
|
||||
onOk() {
|
||||
if (this.closed()) return
|
||||
this.#state.update("ack")
|
||||
}
|
||||
|
||||
onError(code: bigint, reason: string) {
|
||||
if (this.closed()) return
|
||||
|
||||
const err = new Error(`ANNOUNCE_ERROR (${code})` + reason ? `: ${reason}` : "")
|
||||
this.#state.update(err)
|
||||
}
|
||||
}
|
||||
|
||||
export class SubscribeRecv {
|
||||
#control: Control.Stream
|
||||
#objects: Objects
|
||||
#id: bigint
|
||||
#trackId: bigint
|
||||
|
||||
readonly namespace: string
|
||||
readonly track: string
|
||||
|
||||
// The current state of the subscription.
|
||||
#state: "init" | "ack" | "closed" = "init"
|
||||
|
||||
constructor(control: Control.Stream, objects: Objects, msg: Control.Subscribe) {
|
||||
this.#control = control // so we can send messages
|
||||
this.#objects = objects // so we can send objects
|
||||
this.#id = msg.id
|
||||
this.#trackId = msg.trackId
|
||||
this.namespace = msg.namespace
|
||||
this.track = msg.name
|
||||
}
|
||||
|
||||
// Acknowledge the subscription as valid.
|
||||
async ack() {
|
||||
if (this.#state !== "init") return
|
||||
this.#state = "ack"
|
||||
|
||||
// Send the control message.
|
||||
return this.#control.send({ kind: Control.Msg.SubscribeOk, id: this.#id, expires: 0n })
|
||||
}
|
||||
|
||||
// Close the subscription with an error.
|
||||
async close(code = 0n, reason = "") {
|
||||
if (this.#state === "closed") return
|
||||
this.#state = "closed"
|
||||
|
||||
return this.#control.send({
|
||||
kind: Control.Msg.SubscribeDone,
|
||||
id: this.#id,
|
||||
code,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
// Create a writable data stream for the entire track
|
||||
async serve(props?: { priority: number }): Promise<TrackWriter> {
|
||||
return this.#objects.send({
|
||||
type: StreamType.Track,
|
||||
sub: this.#id,
|
||||
track: this.#trackId,
|
||||
priority: props?.priority ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Create a writable data stream for a group within the track
|
||||
async group(props: { group: number; priority?: number }): Promise<GroupWriter> {
|
||||
return this.#objects.send({
|
||||
type: StreamType.Group,
|
||||
sub: this.#id,
|
||||
track: this.#trackId,
|
||||
group: props.group,
|
||||
priority: props.priority ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Create a writable data stream for a single object within the track
|
||||
async object(props: { group: number; object: number; priority?: number }): Promise<ObjectWriter> {
|
||||
return this.#objects.send({
|
||||
type: StreamType.Object,
|
||||
sub: this.#id,
|
||||
track: this.#trackId,
|
||||
group: props.group,
|
||||
object: props.object,
|
||||
priority: props.priority ?? 0,
|
||||
status: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
163
packages/moq/transport/setup.ts
Normal file
163
packages/moq/transport/setup.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Reader, Writer } from "./stream"
|
||||
|
||||
export type Message = Client | Server
|
||||
export type Role = "publisher" | "subscriber" | "both"
|
||||
|
||||
export enum Version {
|
||||
DRAFT_00 = 0xff000000,
|
||||
DRAFT_01 = 0xff000001,
|
||||
DRAFT_02 = 0xff000002,
|
||||
DRAFT_03 = 0xff000003,
|
||||
DRAFT_04 = 0xff000004,
|
||||
KIXEL_00 = 0xbad00,
|
||||
KIXEL_01 = 0xbad01,
|
||||
}
|
||||
|
||||
// NOTE: These are forked from moq-transport-00.
|
||||
// 1. messages lack a sized length
|
||||
// 2. parameters are not optional and written in order (role + path)
|
||||
// 3. role indicates local support only, not remote support
|
||||
|
||||
export interface Client {
|
||||
versions: Version[]
|
||||
role: Role
|
||||
params?: Parameters
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
version: Version
|
||||
params?: Parameters
|
||||
}
|
||||
|
||||
export class Stream {
|
||||
recv: Decoder
|
||||
send: Encoder
|
||||
|
||||
constructor(r: Reader, w: Writer) {
|
||||
this.recv = new Decoder(r)
|
||||
this.send = new Encoder(w)
|
||||
}
|
||||
}
|
||||
|
||||
export type Parameters = Map<bigint, Uint8Array>
|
||||
|
||||
export class Decoder {
|
||||
r: Reader
|
||||
|
||||
constructor(r: Reader) {
|
||||
this.r = r
|
||||
}
|
||||
|
||||
async client(): Promise<Client> {
|
||||
const type = await this.r.u53()
|
||||
if (type !== 0x40) throw new Error(`client SETUP type must be 0x40, got ${type}`)
|
||||
|
||||
const count = await this.r.u53()
|
||||
|
||||
const versions = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const version = await this.r.u53()
|
||||
versions.push(version)
|
||||
}
|
||||
|
||||
const params = await this.parameters()
|
||||
const role = this.role(params?.get(0n))
|
||||
|
||||
return {
|
||||
versions,
|
||||
role,
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
async server(): Promise<Server> {
|
||||
const type = await this.r.u53()
|
||||
if (type !== 0x41) throw new Error(`server SETUP type must be 0x41, got ${type}`)
|
||||
|
||||
const version = await this.r.u53()
|
||||
const params = await this.parameters()
|
||||
|
||||
return {
|
||||
version,
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
private async parameters(): Promise<Parameters | undefined> {
|
||||
const count = await this.r.u53()
|
||||
if (count == 0) return undefined
|
||||
|
||||
const params = new Map<bigint, Uint8Array>()
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = await this.r.u62()
|
||||
const size = await this.r.u53()
|
||||
const value = await this.r.read(size)
|
||||
|
||||
if (params.has(id)) {
|
||||
throw new Error(`duplicate parameter id: ${id}`)
|
||||
}
|
||||
|
||||
params.set(id, value)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
role(raw: Uint8Array | undefined): Role {
|
||||
if (!raw) throw new Error("missing role parameter")
|
||||
if (raw.length != 1) throw new Error("multi-byte varint not supported")
|
||||
|
||||
switch (raw[0]) {
|
||||
case 1:
|
||||
return "publisher"
|
||||
case 2:
|
||||
return "subscriber"
|
||||
case 3:
|
||||
return "both"
|
||||
default:
|
||||
throw new Error(`invalid role: ${raw[0]}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Encoder {
|
||||
w: Writer
|
||||
|
||||
constructor(w: Writer) {
|
||||
this.w = w
|
||||
}
|
||||
|
||||
async client(c: Client) {
|
||||
await this.w.u53(0x40)
|
||||
await this.w.u53(c.versions.length)
|
||||
for (const v of c.versions) {
|
||||
await this.w.u53(v)
|
||||
}
|
||||
|
||||
// I hate it
|
||||
const params = c.params ?? new Map()
|
||||
params.set(0n, new Uint8Array([c.role == "publisher" ? 1 : c.role == "subscriber" ? 2 : 3]))
|
||||
await this.parameters(params)
|
||||
}
|
||||
|
||||
async server(s: Server) {
|
||||
await this.w.u53(0x41)
|
||||
await this.w.u53(s.version)
|
||||
await this.parameters(s.params)
|
||||
}
|
||||
|
||||
private async parameters(p: Parameters | undefined) {
|
||||
if (!p) {
|
||||
await this.w.u8(0)
|
||||
return
|
||||
}
|
||||
|
||||
await this.w.u53(p.size)
|
||||
for (const [id, value] of p) {
|
||||
await this.w.u62(id)
|
||||
await this.w.u53(value.length)
|
||||
await this.w.write(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
270
packages/moq/transport/stream.ts
Normal file
270
packages/moq/transport/stream.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
const MAX_U6 = Math.pow(2, 6) - 1
|
||||
const MAX_U14 = Math.pow(2, 14) - 1
|
||||
const MAX_U30 = Math.pow(2, 30) - 1
|
||||
const MAX_U31 = Math.pow(2, 31) - 1
|
||||
const MAX_U53 = Number.MAX_SAFE_INTEGER
|
||||
const MAX_U62: bigint = 2n ** 62n - 1n
|
||||
|
||||
// Reader wraps a stream and provides convience methods for reading pieces from a stream
|
||||
// Unfortunately we can't use a BYOB reader because it's not supported with WebTransport+WebWorkers yet.
|
||||
export class Reader {
|
||||
#buffer: Uint8Array
|
||||
#stream: ReadableStream<Uint8Array>
|
||||
#reader: ReadableStreamDefaultReader<Uint8Array>
|
||||
|
||||
constructor(buffer: Uint8Array, stream: ReadableStream<Uint8Array>) {
|
||||
this.#buffer = buffer
|
||||
this.#stream = stream
|
||||
this.#reader = this.#stream.getReader()
|
||||
}
|
||||
|
||||
// Adds more data to the buffer, returning true if more data was added.
|
||||
async #fill(): Promise<boolean> {
|
||||
const result = await this.#reader.read()
|
||||
if (result.done) {
|
||||
return false
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(result.value)
|
||||
|
||||
if (this.#buffer.byteLength == 0) {
|
||||
this.#buffer = buffer
|
||||
} else {
|
||||
const temp = new Uint8Array(this.#buffer.byteLength + buffer.byteLength)
|
||||
temp.set(this.#buffer)
|
||||
temp.set(buffer, this.#buffer.byteLength)
|
||||
this.#buffer = temp
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Add more data to the buffer until it's at least size bytes.
|
||||
async #fillTo(size: number) {
|
||||
while (this.#buffer.byteLength < size) {
|
||||
if (!(await this.#fill())) {
|
||||
throw new Error("unexpected end of stream")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Consumes the first size bytes of the buffer.
|
||||
#slice(size: number): Uint8Array {
|
||||
const result = new Uint8Array(this.#buffer.buffer, this.#buffer.byteOffset, size)
|
||||
this.#buffer = new Uint8Array(this.#buffer.buffer, this.#buffer.byteOffset + size)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async read(size: number): Promise<Uint8Array> {
|
||||
if (size == 0) return new Uint8Array()
|
||||
|
||||
await this.#fillTo(size)
|
||||
return this.#slice(size)
|
||||
}
|
||||
|
||||
async readAll(): Promise<Uint8Array> {
|
||||
// eslint-disable-next-line no-empty
|
||||
while (await this.#fill()) {}
|
||||
return this.#slice(this.#buffer.byteLength)
|
||||
}
|
||||
|
||||
async string(maxLength?: number): Promise<string> {
|
||||
const length = await this.u53()
|
||||
if (maxLength !== undefined && length > maxLength) {
|
||||
throw new Error(`string length ${length} exceeds max length ${maxLength}`)
|
||||
}
|
||||
|
||||
const buffer = await this.read(length)
|
||||
return new TextDecoder().decode(buffer)
|
||||
}
|
||||
|
||||
async u8(): Promise<number> {
|
||||
await this.#fillTo(1)
|
||||
return this.#slice(1)[0]
|
||||
}
|
||||
|
||||
// Returns a Number using 53-bits, the max Javascript can use for integer math
|
||||
async u53(): Promise<number> {
|
||||
const v = await this.u62()
|
||||
if (v > MAX_U53) {
|
||||
throw new Error("value larger than 53-bits; use v62 instead")
|
||||
}
|
||||
|
||||
return Number(v)
|
||||
}
|
||||
|
||||
// NOTE: Returns a bigint instead of a number since it may be larger than 53-bits
|
||||
async u62(): Promise<bigint> {
|
||||
await this.#fillTo(1)
|
||||
const size = (this.#buffer[0] & 0xc0) >> 6
|
||||
|
||||
if (size == 0) {
|
||||
const first = this.#slice(1)[0]
|
||||
return BigInt(first) & 0x3fn
|
||||
} else if (size == 1) {
|
||||
await this.#fillTo(2)
|
||||
const slice = this.#slice(2)
|
||||
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength)
|
||||
|
||||
return BigInt(view.getInt16(0)) & 0x3fffn
|
||||
} else if (size == 2) {
|
||||
await this.#fillTo(4)
|
||||
const slice = this.#slice(4)
|
||||
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength)
|
||||
|
||||
return BigInt(view.getUint32(0)) & 0x3fffffffn
|
||||
} else if (size == 3) {
|
||||
await this.#fillTo(8)
|
||||
const slice = this.#slice(8)
|
||||
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength)
|
||||
|
||||
return view.getBigUint64(0) & 0x3fffffffffffffffn
|
||||
} else {
|
||||
throw new Error("impossible")
|
||||
}
|
||||
}
|
||||
|
||||
async done(): Promise<boolean> {
|
||||
if (this.#buffer.byteLength > 0) return false
|
||||
return !(await this.#fill())
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.#reader.releaseLock()
|
||||
await this.#stream.cancel()
|
||||
}
|
||||
|
||||
release(): [Uint8Array, ReadableStream<Uint8Array>] {
|
||||
this.#reader.releaseLock()
|
||||
return [this.#buffer, this.#stream]
|
||||
}
|
||||
}
|
||||
|
||||
// Writer wraps a stream and writes chunks of data
|
||||
export class Writer {
|
||||
#scratch: Uint8Array
|
||||
#writer: WritableStreamDefaultWriter<Uint8Array>
|
||||
#stream: WritableStream<Uint8Array>
|
||||
|
||||
constructor(stream: WritableStream<Uint8Array>) {
|
||||
this.#stream = stream
|
||||
this.#scratch = new Uint8Array(8)
|
||||
this.#writer = this.#stream.getWriter()
|
||||
}
|
||||
|
||||
async u8(v: number) {
|
||||
await this.write(setUint8(this.#scratch, v))
|
||||
}
|
||||
|
||||
async i32(v: number) {
|
||||
if (Math.abs(v) > MAX_U31) {
|
||||
throw new Error(`overflow, value larger than 32-bits: ${v}`)
|
||||
}
|
||||
|
||||
// We don't use a VarInt, so it always takes 4 bytes.
|
||||
// This could be improved but nothing is standardized yet.
|
||||
await this.write(setInt32(this.#scratch, v))
|
||||
}
|
||||
|
||||
async u53(v: number) {
|
||||
if (v < 0) {
|
||||
throw new Error(`underflow, value is negative: ${v}`)
|
||||
} else if (v > MAX_U53) {
|
||||
throw new Error(`overflow, value larger than 53-bits: ${v}`)
|
||||
}
|
||||
|
||||
await this.write(setVint53(this.#scratch, v))
|
||||
}
|
||||
|
||||
async u62(v: bigint) {
|
||||
if (v < 0) {
|
||||
throw new Error(`underflow, value is negative: ${v}`)
|
||||
} else if (v >= MAX_U62) {
|
||||
throw new Error(`overflow, value larger than 62-bits: ${v}`)
|
||||
}
|
||||
|
||||
await this.write(setVint62(this.#scratch, v))
|
||||
}
|
||||
|
||||
async write(v: Uint8Array) {
|
||||
await this.#writer.write(v)
|
||||
}
|
||||
|
||||
async string(str: string) {
|
||||
const data = new TextEncoder().encode(str)
|
||||
await this.u53(data.byteLength)
|
||||
await this.write(data)
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.#writer.releaseLock()
|
||||
await this.#stream.close()
|
||||
}
|
||||
|
||||
release(): WritableStream<Uint8Array> {
|
||||
this.#writer.releaseLock()
|
||||
return this.#stream
|
||||
}
|
||||
}
|
||||
|
||||
function setUint8(dst: Uint8Array, v: number): Uint8Array {
|
||||
dst[0] = v
|
||||
return dst.slice(0, 1)
|
||||
}
|
||||
|
||||
function setUint16(dst: Uint8Array, v: number): Uint8Array {
|
||||
const view = new DataView(dst.buffer, dst.byteOffset, 2)
|
||||
view.setUint16(0, v)
|
||||
|
||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
||||
}
|
||||
|
||||
function setInt32(dst: Uint8Array, v: number): Uint8Array {
|
||||
const view = new DataView(dst.buffer, dst.byteOffset, 4)
|
||||
view.setInt32(0, v)
|
||||
|
||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
||||
}
|
||||
|
||||
function setUint32(dst: Uint8Array, v: number): Uint8Array {
|
||||
const view = new DataView(dst.buffer, dst.byteOffset, 4)
|
||||
view.setUint32(0, v)
|
||||
|
||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
||||
}
|
||||
|
||||
function setVint53(dst: Uint8Array, v: number): Uint8Array {
|
||||
if (v <= MAX_U6) {
|
||||
return setUint8(dst, v)
|
||||
} else if (v <= MAX_U14) {
|
||||
return setUint16(dst, v | 0x4000)
|
||||
} else if (v <= MAX_U30) {
|
||||
return setUint32(dst, v | 0x80000000)
|
||||
} else if (v <= MAX_U53) {
|
||||
return setUint64(dst, BigInt(v) | 0xc000000000000000n)
|
||||
} else {
|
||||
throw new Error(`overflow, value larger than 53-bits: ${v}`)
|
||||
}
|
||||
}
|
||||
|
||||
function setVint62(dst: Uint8Array, v: bigint): Uint8Array {
|
||||
if (v < MAX_U6) {
|
||||
return setUint8(dst, Number(v))
|
||||
} else if (v < MAX_U14) {
|
||||
return setUint16(dst, Number(v) | 0x4000)
|
||||
} else if (v <= MAX_U30) {
|
||||
return setUint32(dst, Number(v) | 0x80000000)
|
||||
} else if (v <= MAX_U62) {
|
||||
return setUint64(dst, BigInt(v) | 0xc000000000000000n)
|
||||
} else {
|
||||
throw new Error(`overflow, value larger than 62-bits: ${v}`)
|
||||
}
|
||||
}
|
||||
|
||||
function setUint64(dst: Uint8Array, v: bigint): Uint8Array {
|
||||
const view = new DataView(dst.buffer, dst.byteOffset, 8)
|
||||
view.setBigUint64(0, v)
|
||||
|
||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
||||
}
|
||||
197
packages/moq/transport/subscriber.ts
Normal file
197
packages/moq/transport/subscriber.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import * as Control from "./control"
|
||||
import { Queue, Watch } from "../common/async"
|
||||
import { Objects } from "./objects"
|
||||
import type { TrackReader, GroupReader, ObjectReader } from "./objects"
|
||||
|
||||
export class Subscriber {
|
||||
// Use to send control messages.
|
||||
#control: Control.Stream
|
||||
|
||||
// Use to send objects.
|
||||
#objects: Objects
|
||||
|
||||
// Announced broadcasts.
|
||||
#announce = new Map<string, AnnounceRecv>()
|
||||
#announceQueue = new Watch<AnnounceRecv[]>([])
|
||||
|
||||
// Our subscribed tracks.
|
||||
#subscribe = new Map<bigint, SubscribeSend>()
|
||||
#subscribeNext = 0n
|
||||
|
||||
constructor(control: Control.Stream, objects: Objects) {
|
||||
this.#control = control
|
||||
this.#objects = objects
|
||||
}
|
||||
|
||||
announced(): Watch<AnnounceRecv[]> {
|
||||
return this.#announceQueue
|
||||
}
|
||||
|
||||
async recv(msg: Control.Publisher) {
|
||||
if (msg.kind == Control.Msg.Announce) {
|
||||
await this.recvAnnounce(msg)
|
||||
} else if (msg.kind == Control.Msg.Unannounce) {
|
||||
this.recvUnannounce(msg)
|
||||
} else if (msg.kind == Control.Msg.SubscribeOk) {
|
||||
this.recvSubscribeOk(msg)
|
||||
} else if (msg.kind == Control.Msg.SubscribeError) {
|
||||
await this.recvSubscribeError(msg)
|
||||
} else if (msg.kind == Control.Msg.SubscribeDone) {
|
||||
await this.recvSubscribeDone(msg)
|
||||
} else {
|
||||
throw new Error(`unknown control message`) // impossible
|
||||
}
|
||||
}
|
||||
|
||||
async recvAnnounce(msg: Control.Announce) {
|
||||
if (this.#announce.has(msg.namespace)) {
|
||||
throw new Error(`duplicate announce for namespace: ${msg.namespace}`)
|
||||
}
|
||||
|
||||
await this.#control.send({ kind: Control.Msg.AnnounceOk, namespace: msg.namespace })
|
||||
|
||||
const announce = new AnnounceRecv(this.#control, msg.namespace)
|
||||
this.#announce.set(msg.namespace, announce)
|
||||
|
||||
this.#announceQueue.update((queue) => [...queue, announce])
|
||||
}
|
||||
|
||||
recvUnannounce(_msg: Control.Unannounce) {
|
||||
throw new Error(`TODO Unannounce`)
|
||||
}
|
||||
|
||||
async subscribe(namespace: string, track: string) {
|
||||
const id = this.#subscribeNext++
|
||||
|
||||
const subscribe = new SubscribeSend(this.#control, id, namespace, track)
|
||||
this.#subscribe.set(id, subscribe)
|
||||
|
||||
await this.#control.send({
|
||||
kind: Control.Msg.Subscribe,
|
||||
id,
|
||||
trackId: id,
|
||||
namespace,
|
||||
name: track,
|
||||
location: {
|
||||
mode: "latest_group",
|
||||
},
|
||||
})
|
||||
|
||||
return subscribe
|
||||
}
|
||||
|
||||
recvSubscribeOk(msg: Control.SubscribeOk) {
|
||||
const subscribe = this.#subscribe.get(msg.id)
|
||||
if (!subscribe) {
|
||||
throw new Error(`subscribe ok for unknown id: ${msg.id}`)
|
||||
}
|
||||
|
||||
subscribe.onOk()
|
||||
}
|
||||
|
||||
async recvSubscribeError(msg: Control.SubscribeError) {
|
||||
const subscribe = this.#subscribe.get(msg.id)
|
||||
if (!subscribe) {
|
||||
throw new Error(`subscribe error for unknown id: ${msg.id}`)
|
||||
}
|
||||
|
||||
await subscribe.onError(msg.code, msg.reason)
|
||||
}
|
||||
|
||||
async recvSubscribeDone(msg: Control.SubscribeDone) {
|
||||
const subscribe = this.#subscribe.get(msg.id)
|
||||
if (!subscribe) {
|
||||
throw new Error(`subscribe error for unknown id: ${msg.id}`)
|
||||
}
|
||||
|
||||
await subscribe.onError(msg.code, msg.reason)
|
||||
}
|
||||
|
||||
async recvObject(reader: TrackReader | GroupReader | ObjectReader) {
|
||||
const subscribe = this.#subscribe.get(reader.header.track)
|
||||
if (!subscribe) {
|
||||
throw new Error(`data for for unknown track: ${reader.header.track}`)
|
||||
}
|
||||
|
||||
await subscribe.onData(reader)
|
||||
}
|
||||
}
|
||||
|
||||
export class AnnounceRecv {
|
||||
#control: Control.Stream
|
||||
|
||||
readonly namespace: string
|
||||
|
||||
// The current state of the announce
|
||||
#state: "init" | "ack" | "closed" = "init"
|
||||
|
||||
constructor(control: Control.Stream, namespace: string) {
|
||||
this.#control = control // so we can send messages
|
||||
this.namespace = namespace
|
||||
}
|
||||
|
||||
// Acknowledge the subscription as valid.
|
||||
async ok() {
|
||||
if (this.#state !== "init") return
|
||||
this.#state = "ack"
|
||||
|
||||
// Send the control message.
|
||||
return this.#control.send({ kind: Control.Msg.AnnounceOk, namespace: this.namespace })
|
||||
}
|
||||
|
||||
async close(code = 0n, reason = "") {
|
||||
if (this.#state === "closed") return
|
||||
this.#state = "closed"
|
||||
|
||||
return this.#control.send({ kind: Control.Msg.AnnounceError, namespace: this.namespace, code, reason })
|
||||
}
|
||||
}
|
||||
|
||||
export class SubscribeSend {
|
||||
#control: Control.Stream
|
||||
#id: bigint
|
||||
|
||||
readonly namespace: string
|
||||
readonly track: string
|
||||
|
||||
// A queue of received streams for this subscription.
|
||||
#data = new Queue<TrackReader | GroupReader | ObjectReader>()
|
||||
|
||||
constructor(control: Control.Stream, id: bigint, namespace: string, track: string) {
|
||||
this.#control = control // so we can send messages
|
||||
this.#id = id
|
||||
this.namespace = namespace
|
||||
this.track = track
|
||||
}
|
||||
|
||||
async close(_code = 0n, _reason = "") {
|
||||
// TODO implement unsubscribe
|
||||
// await this.#inner.sendReset(code, reason)
|
||||
}
|
||||
|
||||
onOk() {
|
||||
// noop
|
||||
}
|
||||
|
||||
async onError(code: bigint, reason: string) {
|
||||
if (code == 0n) {
|
||||
return await this.#data.close()
|
||||
}
|
||||
|
||||
if (reason !== "") {
|
||||
reason = `: ${reason}`
|
||||
}
|
||||
|
||||
const err = new Error(`SUBSCRIBE_ERROR (${code})${reason}`)
|
||||
return await this.#data.abort(err)
|
||||
}
|
||||
|
||||
async onData(reader: TrackReader | GroupReader | ObjectReader) {
|
||||
if (!this.#data.closed()) await this.#data.push(reader)
|
||||
}
|
||||
|
||||
// Receive the next a readable data stream
|
||||
async data() {
|
||||
return await this.#data.next()
|
||||
}
|
||||
}
|
||||
9
packages/moq/transport/tsconfig.json
Normal file
9
packages/moq/transport/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."],
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user