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,218 @@
import { Connection } from "../../transport"
import { asError } from "../../common/error"
export interface CommonTrackFields {
namespace?: string
packaging?: string
renderGroup?: number
altGroup?: number
}
export interface Root {
version: number
streamingFormat: number
streamingFormatVersion: string
supportsDeltaUpdates: boolean
commonTrackFields: CommonTrackFields
tracks: Track[]
}
export function encode(catalog: Root): Uint8Array {
const encoder = new TextEncoder()
const str = JSON.stringify(catalog)
return encoder.encode(str)
}
export function decode(raw: Uint8Array): Root {
const decoder = new TextDecoder()
const str = decoder.decode(raw)
const catalog = JSON.parse(str)
if (!isRoot(catalog)) {
throw new Error("invalid catalog")
}
// Merge common track fields into each track.
for (const track of catalog.tracks) {
track.altGroup ??= catalog.commonTrackFields.altGroup
track.namespace ??= catalog.commonTrackFields.namespace
track.packaging ??= catalog.commonTrackFields.packaging
track.renderGroup ??= catalog.commonTrackFields.renderGroup
}
return catalog
}
export async function fetch(connection: Connection, namespace: string): Promise<Root> {
const subscribe = await connection.subscribe(namespace, ".catalog")
try {
const segment = await subscribe.data()
if (!segment) throw new Error("no catalog data")
const chunk = await segment.read()
if (!chunk) throw new Error("no catalog chunk")
await segment.close()
await subscribe.close() // we done
if (chunk.payload instanceof Uint8Array) {
return decode(chunk.payload)
} else {
throw new Error("invalid catalog chunk")
}
} catch (e) {
const err = asError(e)
// Close the subscription after we're done.
await subscribe.close(1n, err.message)
throw err
}
}
export function isRoot(catalog: any): catalog is Root {
if (!isCatalogFieldValid(catalog, "packaging")) return false
if (!isCatalogFieldValid(catalog, "namespace")) return false
if (!Array.isArray(catalog.tracks)) return false
return catalog.tracks.every((track: any) => isTrack(track))
}
export interface Track {
namespace?: string
name: string
depends?: any[]
packaging?: string
renderGroup?: number
selectionParams: SelectionParams // technically optional but not really
altGroup?: number
initTrack?: string
initData?: string
}
export interface Mp4Track extends Track {
initTrack?: string
initData?: string
selectionParams: Mp4SelectionParams
}
export interface SelectionParams {
codec?: string
mimeType?: string
bitrate?: number
lang?: string
}
export interface Mp4SelectionParams extends SelectionParams {
mimeType: "video/mp4"
}
export interface AudioTrack extends Track {
name: string
selectionParams: AudioSelectionParams
}
export interface AudioSelectionParams extends SelectionParams {
samplerate: number
channelConfig: string
}
export interface VideoTrack extends Track {
name: string
selectionParams: VideoSelectionParams
temporalId?: number
spatialId?: number
}
export interface VideoSelectionParams extends SelectionParams {
width: number
height: number
displayWidth?: number
displayHeight?: number
framerate?: number
}
export function isTrack(track: any): track is Track {
if (typeof track.name !== "string") return false
return true
}
export function isMp4Track(track: any): track is Mp4Track {
if (!isTrack(track)) return false
if (typeof track.initTrack !== "string" && typeof track.initData !== "string") return false
if (typeof track.selectionParams.mimeType !== "string") return false
return true
}
export function isVideoTrack(track: any): track is VideoTrack {
if (!isTrack(track)) return false
return isVideoSelectionParams(track.selectionParams)
}
export function isVideoSelectionParams(params: any): params is VideoSelectionParams {
if (typeof params.width !== "number") return false
if (typeof params.height !== "number") return false
return true
}
export function isAudioTrack(track: any): track is AudioTrack {
if (!isTrack(track)) return false
return isAudioSelectionParams(track.selectionParams)
}
export function isAudioSelectionParams(params: any): params is AudioSelectionParams {
if (typeof params.channelConfig !== "string") return false
if (typeof params.samplerate !== "number") return false
return true
}
function isCatalogFieldValid(catalog: any, field: string): boolean {
//packaging,namespace if common would be listed in commonTrackFields but if fields
//in commonTrackFields are mentiond in Tracks , the fields in Tracks precedes
function isValidPackaging(packaging: any): boolean {
return packaging === "cmaf" || packaging === "loc"
}
function isValidNamespace(namespace: any): boolean {
return typeof namespace === "string"
}
let isValidField: (value: any) => boolean
if (field === "packaging") {
isValidField = isValidPackaging
} else if (field === "namespace") {
isValidField = isValidNamespace
} else {
throw new Error(`Invalid field: ${field}`)
}
if (catalog.commonTrackFields[field] !== undefined && !isValidField(catalog.commonTrackFields[field])) {
return false
}
for (const track of catalog.tracks) {
if (track[field] !== undefined && !isValidField(track[field])) {
return false
}
}
return true
}
export function isMediaTrack(track: any): track is Track {
if (track.name.toLowerCase().includes("audio") || track.name.toLowerCase().includes("video")) {
return true
}
if (track.selectionParams && track.selectionParams.codec) {
const codec = track.selectionParams.codec.toLowerCase()
const acceptedCodecs = ["mp4a", "avc1"]
for (const acceptedCodec of acceptedCodecs) {
if (codec.includes(acceptedCodec)) {
return true
}
}
}
return false
}

View File

@@ -0,0 +1,37 @@
// Rename some stuff so it's on brand.
// We need a separate file so this file can use the rename too.
import * as MP4 from "./rename"
export * from "./rename"
export * from "./parser"
export function isAudioTrack(track: MP4.Track): track is MP4.AudioTrack {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return (track as MP4.AudioTrack).audio !== undefined
}
export function isVideoTrack(track: MP4.Track): track is MP4.VideoTrack {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return (track as MP4.VideoTrack).video !== undefined
}
// TODO contribute to mp4box
MP4.BoxParser.dOpsBox.prototype.write = function (stream: MP4.Stream) {
this.size = this.ChannelMappingFamily === 0 ? 11 : 13 + this.ChannelMapping!.length
this.writeHeader(stream)
stream.writeUint8(this.Version)
stream.writeUint8(this.OutputChannelCount)
stream.writeUint16(this.PreSkip)
stream.writeUint32(this.InputSampleRate)
stream.writeInt16(this.OutputGain)
stream.writeUint8(this.ChannelMappingFamily)
if (this.ChannelMappingFamily !== 0) {
stream.writeUint8(this.StreamCount!)
stream.writeUint8(this.CoupledCount!)
for (const mapping of this.ChannelMapping!) {
stream.writeUint8(mapping)
}
}
}

View File

@@ -0,0 +1,71 @@
import * as MP4 from "./index"
export interface Frame {
track: MP4.Track // The track this frame belongs to
sample: MP4.Sample // The actual sample contain the frame data
}
// Decode a MP4 container into individual samples.
export class Parser {
info!: MP4.Info
#mp4 = MP4.New()
#offset = 0
#samples: Array<Frame> = []
constructor(init: Uint8Array) {
this.#mp4.onError = (err) => {
console.error("MP4 error", err)
}
this.#mp4.onReady = (info: MP4.Info) => {
this.info = info
// Extract all of the tracks, because we don't know if it's audio or video.
for (const track of info.tracks) {
this.#mp4.setExtractionOptions(track.id, track, { nbSamples: 1 })
}
}
this.#mp4.onSamples = (_track_id: number, track: MP4.Track, samples: MP4.Sample[]) => {
for (const sample of samples) {
this.#samples.push({ track, sample })
}
}
this.#mp4.start()
// For some reason we need to modify the underlying ArrayBuffer with offset
const copy = new Uint8Array(init)
const buffer = copy.buffer as MP4.ArrayBuffer
buffer.fileStart = this.#offset
this.#mp4.appendBuffer(buffer)
this.#offset += buffer.byteLength
this.#mp4.flush()
if (!this.info) {
throw new Error("could not parse MP4 info")
}
}
decode(chunk: Uint8Array): Array<Frame> {
const copy = new Uint8Array(chunk)
// For some reason we need to modify the underlying ArrayBuffer with offset
const buffer = copy.buffer as MP4.ArrayBuffer
buffer.fileStart = this.#offset
// Parse the data
this.#mp4.appendBuffer(buffer)
this.#mp4.flush()
this.#offset += buffer.byteLength
const samples = [...this.#samples]
this.#samples.length = 0
return samples
}
}

View File

@@ -0,0 +1,13 @@
// Rename some stuff so it's on brand.
export { createFile as New, DataStream as Stream, ISOFile, BoxParser, Log } from "mp4box"
export type {
MP4ArrayBuffer as ArrayBuffer,
MP4Info as Info,
MP4Track as Track,
MP4AudioTrack as AudioTrack,
MP4VideoTrack as VideoTrack,
Sample,
TrackOptions,
SampleOptions,
} from "mp4box"

View File

@@ -0,0 +1,15 @@
{
"extends": "../tsconfig.json",
"include": ["."],
"compilerOptions": {
"types": ["mp4box"]
},
"references": [
{
"path": "../transport"
},
{
"path": "../common"
}
]
}