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:
Wanjohi
2024-09-26 21:34:42 +03:00
committed by GitHub
parent c4a6895726
commit bae089e223
74 changed files with 7107 additions and 96 deletions

View 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,
}
}
}

View 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)
}
}
}

View 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)
}
}

View 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"

View 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()
}
}

View 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,
})
}
}

View 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)
}
}
}

View 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)
}

View 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()
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"include": ["."],
"references": [
{
"path": "../common"
}
]
}