feat: Add streaming support (#125)

This adds:
- [x] Keyboard and mouse handling on the frontend
- [x] Video and audio streaming from the backend to the frontend
- [x] Input server that works with Websockets

Update - 17/11
- [ ] Master docker container to run this
- [ ] Steam runtime
- [ ] Entrypoint.sh

---------

Co-authored-by: Kristian Ollikainen <14197772+DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Kristian Ollikainen <DatCaptainHorse@users.noreply.github.com>
This commit is contained in:
Wanjohi
2024-12-08 14:54:56 +03:00
committed by GitHub
parent 5eb21eeadb
commit 379db1c87b
137 changed files with 12737 additions and 5234 deletions

View File

@@ -8,7 +8,7 @@ module.exports = {
"prettier",
],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "prettier"],
plugins: ["@typescript-eslint", "prettier", "solid"],
root: true,
env: {
browser: true,

View File

@@ -1,7 +1,7 @@
export class Deferred<T> {
promise: Promise<T>
resolve!: (value: T | PromiseLike<T>) => void
reject!: (reason: any) => void
reject!: (reason: unknown) => void
pending = true
constructor() {
@@ -35,16 +35,19 @@ export class Watch<T> {
update(v: T | ((v: T) => T)) {
if (!this.#next.pending) {
throw new Error("already closed")
throw new Error("closed")
}
// If we're given a function, call it with the current value
let value: T
if (v instanceof Function) {
v = v(this.#current[0])
value = v(this.#current[0])
} else {
value = v
}
const next = new Deferred<WatchNext<T>>()
this.#current = [v, next.promise]
this.#current = [value, next.promise]
this.#next.resolve(this.#current)
this.#next = next
}
@@ -53,6 +56,10 @@ export class Watch<T> {
this.#current[1] = undefined
this.#next.resolve(this.#current)
}
closed() {
return !this.#next.pending
}
}
// Wakes up a multiple consumers.
@@ -88,6 +95,7 @@ export class Queue<T> {
}
async push(v: T) {
if (this.#closed) throw new Error("closed")
const w = this.#stream.writable.getWriter()
await w.write(v)
w.releaseLock()

View File

@@ -1,14 +1,14 @@
// I hate javascript
export function asError(e: any): Error {
export function asError(e: unknown): Error {
if (e instanceof Error) {
return e
} else if (typeof e === "string") {
return new Error(e)
} else {
return new Error(String(e))
}
if (typeof e === "string") {
return new Error(e)
}
return new Error(String(e))
}
export function isError(e: any): e is Error {
export function isError(e: unknown): e is Error {
return e instanceof Error
}

View File

@@ -0,0 +1,11 @@
export function decode(str: string): Uint8Array {
const bytes = new Uint8Array(str.length / 2)
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = Number.parseInt(str.slice(2 * i, 2 * i + 2), 16)
}
return bytes
}
export function encode(_bytes: Uint8Array): string {
throw "todo"
}

View File

@@ -2,8 +2,8 @@
enum STATE {
READ_POS = 0, // The current read position
WRITE_POS, // The current write position
LENGTH, // Clever way of saving the total number of enums values.
WRITE_POS = 1, // The current write position
LENGTH = 2, // Clever way of saving the total number of enums values.
}
interface FrameCopyToOptions {
@@ -62,16 +62,12 @@ export class Ring {
const readPos = Atomics.load(this.state, STATE.READ_POS)
const writePos = Atomics.load(this.state, STATE.WRITE_POS)
const startPos = writePos
let endPos = writePos + frame.numberOfFrames
const available = this.capacity - (writePos - readPos)
if (available <= 0) return 0
if (endPos > readPos + this.capacity) {
endPos = readPos + this.capacity
if (endPos <= startPos) {
// No space to write
return 0
}
}
const toWrite = Math.min(frame.numberOfFrames, available)
const startPos = writePos
const endPos = writePos + toWrite
const startIndex = startPos % this.capacity
const endIndex = endPos % this.capacity
@@ -114,7 +110,7 @@ export class Ring {
Atomics.store(this.state, STATE.WRITE_POS, endPos)
return endPos - startPos
return toWrite
}
read(dst: Float32Array[]): number {

View File

@@ -1,15 +1,67 @@
import { Deferred } from "../common/async"
import type { Frame } from "../karp/frame"
import type { Group, Track } from "../transfork"
import { Closed } from "../transfork/error"
const SUPPORTED = [
// TODO support AAC
// "mp4a"
"Opus",
]
export class Packer {
#source: MediaStreamTrackProcessor<AudioData>
#encoder: Encoder
#data: Track
#current?: Group
constructor(track: MediaStreamAudioTrack, encoder: Encoder, data: Track) {
this.#source = new MediaStreamTrackProcessor({ track })
this.#encoder = encoder
this.#data = data
}
async run() {
const output = new WritableStream({
write: (chunk) => this.#write(chunk),
close: () => this.#close(),
abort: (e) => this.#close(e),
})
return this.#source.readable.pipeThrough(this.#encoder.frames).pipeTo(output)
}
#write(frame: Frame) {
// TODO use a fixed interval instead of keyframes (audio)
// TODO actually just align with video
if (!this.#current || frame.type === "key") {
if (this.#current) {
this.#current.close()
}
this.#current = this.#data.appendGroup()
}
this.#current.writeFrame(frame.data)
}
#close(err?: unknown) {
const closed = Closed.from(err)
if (this.#current) {
this.#current.close(closed)
}
this.#data.close(closed)
}
}
export class Encoder {
#encoder!: AudioEncoder
#encoderConfig: AudioEncoderConfig
#decoderConfig?: AudioDecoderConfig
#decoderConfig = new Deferred<AudioDecoderConfig>()
frames: TransformStream<AudioData, AudioDecoderConfig | EncodedAudioChunk>
frames: TransformStream<AudioData, EncodedAudioChunk>
constructor(config: AudioEncoderConfig) {
this.#encoderConfig = config
@@ -21,7 +73,7 @@ export class Encoder {
})
}
#start(controller: TransformStreamDefaultController<AudioDecoderConfig | EncodedAudioChunk>) {
#start(controller: TransformStreamDefaultController<EncodedAudioChunk>) {
this.#encoder = new AudioEncoder({
output: (frame, metadata) => {
this.#enqueue(controller, frame, metadata)
@@ -40,17 +92,16 @@ export class Encoder {
}
#enqueue(
controller: TransformStreamDefaultController<AudioDecoderConfig | EncodedAudioChunk>,
controller: TransformStreamDefaultController<EncodedAudioChunk>,
frame: EncodedAudioChunk,
metadata?: EncodedAudioChunkMetadata,
) {
const config = metadata?.decoderConfig
if (config && !this.#decoderConfig) {
if (config && !this.#decoderConfig.pending) {
const config = metadata.decoderConfig
if (!config) throw new Error("missing decoder config")
controller.enqueue(config)
this.#decoderConfig = config
this.#decoderConfig.resolve(config)
}
controller.enqueue(frame)
@@ -72,4 +123,8 @@ export class Encoder {
get config() {
return this.#encoderConfig
}
async decoderConfig(): Promise<AudioDecoderConfig> {
return await this.#decoderConfig.promise
}
}

View File

@@ -1,15 +1,14 @@
import { Connection, SubscribeRecv } from "../transport"
import { asError } from "../common/error"
import { Segment } from "./segment"
import { Track } from "./track"
import * as Catalog from "../media/catalog"
import * as Catalog from "../karp/catalog"
import * as Transfork from "../transfork"
import * as Audio from "./audio"
import * as Video from "./video"
import { isAudioTrackSettings, isVideoTrackSettings } from "../common/settings"
export interface BroadcastConfig {
namespace: string
connection: Connection
path: string[]
media: MediaStream
id?: number
audio?: AudioEncoderConfig
video?: VideoEncoderConfig
@@ -21,221 +20,89 @@ export interface BroadcastConfigTrack {
}
export class Broadcast {
#tracks = new Map<string, Track>()
readonly config: BroadcastConfig
readonly catalog: Catalog.Root
readonly connection: Connection
readonly namespace: string
#running: Promise<void>
#config: BroadcastConfig
#path: string[]
constructor(config: BroadcastConfig) {
this.connection = config.connection
this.config = config
this.namespace = config.namespace
const id = config.id || new Date().getTime() / 1000
const tracks: Catalog.Track[] = []
this.#config = config
this.#path = config.path.concat(id.toString())
}
for (const media of this.config.media.getTracks()) {
const track = new Track(media, config)
this.#tracks.set(track.name, track)
async publish(connection: Transfork.Connection) {
const broadcast: Catalog.Broadcast = {
path: this.#config.path,
audio: [],
video: [],
}
for (const media of this.#config.media.getTracks()) {
const settings = media.getSettings()
const info = {
name: media.id, // TODO way too verbose
priority: media.kind === "video" ? 1 : 2,
}
const track = new Transfork.Track(this.#config.path.concat(info.name), info.priority)
if (isVideoTrackSettings(settings)) {
if (!config.video) {
if (!this.#config.video) {
throw new Error("no video configuration provided")
}
const video: Catalog.VideoTrack = {
namespace: this.namespace,
name: `${track.name}.m4s`,
initTrack: `${track.name}.mp4`,
selectionParams: {
mimeType: "video/mp4",
codec: config.video.codec,
width: settings.width,
height: settings.height,
framerate: settings.frameRate,
bitrate: config.video.bitrate,
},
const encoder = new Video.Encoder(this.#config.video)
const packer = new Video.Packer(media as MediaStreamVideoTrack, encoder, track)
// TODO handle error
packer.run().catch((err) => console.error("failed to run video packer: ", err))
const decoder = await encoder.decoderConfig()
const description = decoder.description ? new Uint8Array(decoder.description as ArrayBuffer) : undefined
const video: Catalog.Video = {
track: info,
codec: decoder.codec,
description: description,
resolution: { width: settings.width, height: settings.height },
frame_rate: settings.frameRate,
bitrate: this.#config.video.bitrate,
}
tracks.push(video)
broadcast.video.push(video)
} else if (isAudioTrackSettings(settings)) {
if (!config.audio) {
if (!this.#config.audio) {
throw new Error("no audio configuration provided")
}
const audio: Catalog.AudioTrack = {
namespace: this.namespace,
name: `${track.name}.m4s`,
initTrack: `${track.name}.mp4`,
selectionParams: {
mimeType: "audio/ogg",
codec: config.audio.codec,
samplerate: settings.sampleRate,
//sampleSize: settings.sampleSize,
channelConfig: `${settings.channelCount}`,
bitrate: config.audio.bitrate,
},
const encoder = new Audio.Encoder(this.#config.audio)
const packer = new Audio.Packer(media as MediaStreamAudioTrack, encoder, track)
packer.run().catch((err) => console.error("failed to run audio packer: ", err)) // TODO handle error
const decoder = await encoder.decoderConfig()
const audio: Catalog.Audio = {
track: info,
codec: decoder.codec,
sample_rate: settings.sampleRate,
channel_count: settings.channelCount,
bitrate: this.#config.audio.bitrate,
}
tracks.push(audio)
broadcast.audio.push(audio)
} else {
throw new Error(`unknown track type: ${media.kind}`)
}
connection.publish(track.reader())
}
this.catalog = {
version: 1,
streamingFormat: 1,
streamingFormatVersion: "0.2",
supportsDeltaUpdates: false,
commonTrackFields: {
packaging: "cmaf",
renderGroup: 1,
},
tracks,
}
const track = new Transfork.Track(this.#config.path.concat("catalog.json"), 0)
track.appendGroup().writeFrames(Catalog.encode(broadcast))
this.#running = this.#run()
connection.publish(track.reader())
}
async #run() {
await this.connection.announce(this.namespace)
for (;;) {
const subscriber = await this.connection.subscribed()
if (!subscriber) break
// Run an async task to serve each subscription.
this.#serveSubscribe(subscriber).catch((e) => {
const err = asError(e)
console.warn("failed to serve subscribe", err)
})
}
}
async #serveSubscribe(subscriber: SubscribeRecv) {
try {
const [base, ext] = splitExt(subscriber.track)
if (ext === "catalog") {
await this.#serveCatalog(subscriber, base)
} else if (ext === "mp4") {
await this.#serveInit(subscriber, base)
} else if (ext === "m4s") {
await this.#serveTrack(subscriber, base)
} else {
throw new Error(`unknown subscription: ${subscriber.track}`)
}
} catch (e) {
const err = asError(e)
await subscriber.close(1n, `failed to process subscribe: ${err.message}`)
} finally {
// TODO we can't close subscribers because there's no support for clean termination
// await subscriber.close()
}
}
async #serveCatalog(subscriber: SubscribeRecv, name: string) {
// We only support ".catalog"
if (name !== "") throw new Error(`unknown catalog: ${name}`)
const bytes = Catalog.encode(this.catalog)
// Send a SUBSCRIBE_OK
await subscriber.ack()
const stream = await subscriber.group({ group: 0 })
await stream.write({ object: 0, payload: bytes })
await stream.close()
}
async #serveInit(subscriber: SubscribeRecv, name: string) {
const track = this.#tracks.get(name)
if (!track) throw new Error(`no track with name ${subscriber.track}`)
// Send a SUBSCRIBE_OK
await subscriber.ack()
const init = await track.init()
const stream = await subscriber.group({ group: 0 })
await stream.write({ object: 0, payload: init })
await stream.close()
}
async #serveTrack(subscriber: SubscribeRecv, name: string) {
const track = this.#tracks.get(name)
if (!track) throw new Error(`no track with name ${subscriber.track}`)
// Send a SUBSCRIBE_OK
await subscriber.ack()
const segments = track.segments().getReader()
for (;;) {
const { value: segment, done } = await segments.read()
if (done) break
// Serve the segment and log any errors that occur.
this.#serveSegment(subscriber, segment).catch((e) => {
const err = asError(e)
console.warn("failed to serve segment", err)
})
}
}
async #serveSegment(subscriber: SubscribeRecv, segment: Segment) {
// Create a new stream for each segment.
const stream = await subscriber.group({
group: segment.id,
priority: 0, // TODO
})
let object = 0
// Pipe the segment to the stream.
const chunks = segment.chunks().getReader()
for (;;) {
const { value, done } = await chunks.read()
if (done) break
await stream.write({
object,
payload: value,
})
object += 1
}
await stream.close()
}
// Attach the captured video stream to the given video element.
attach(video: HTMLVideoElement) {
video.srcObject = this.config.media
}
close() {
// TODO implement publish close
}
// Returns the error message when the connection is closed
async closed(): Promise<Error> {
try {
await this.#running
return new Error("closed") // clean termination
} catch (e) {
return asError(e)
}
}
}
function splitExt(s: string): [string, string] {
const i = s.lastIndexOf(".")
if (i < 0) throw new Error(`no extension found`)
return [s.substring(0, i), s.substring(i + 1)]
close() {}
}

View File

@@ -1,7 +0,0 @@
// Extends EncodedVideoChunk, allowing a new "init" type
export interface Chunk {
type: "init" | "key" | "delta"
timestamp: number // microseconds
duration: number // microseconds
data: Uint8Array
}

View File

@@ -1,165 +0,0 @@
import * as MP4 from "../media/mp4"
import { Chunk } from "./chunk"
type DecoderConfig = AudioDecoderConfig | VideoDecoderConfig
type EncodedChunk = EncodedAudioChunk | EncodedVideoChunk
export class Container {
#mp4: MP4.ISOFile
#frame?: EncodedAudioChunk | EncodedVideoChunk // 1 frame buffer
#track?: number
#segment = 0
encode: TransformStream<DecoderConfig | EncodedChunk, Chunk>
constructor() {
this.#mp4 = new MP4.ISOFile()
this.#mp4.init()
this.encode = new TransformStream({
transform: (frame, controller) => {
if (isDecoderConfig(frame)) {
return this.#init(frame, controller)
} else {
return this.#enqueue(frame, controller)
}
},
})
}
#init(frame: DecoderConfig, controller: TransformStreamDefaultController<Chunk>) {
if (this.#track) throw new Error("duplicate decoder config")
let codec = frame.codec.substring(0, 4)
if (codec == "opus") {
codec = "Opus"
}
const options: MP4.TrackOptions = {
type: codec,
timescale: 1_000_000,
}
if (isVideoConfig(frame)) {
options.width = frame.codedWidth
options.height = frame.codedHeight
} else {
options.channel_count = frame.numberOfChannels
options.samplerate = frame.sampleRate
}
if (!frame.description) throw new Error("missing frame description")
const desc = frame.description as ArrayBufferLike
if (codec === "avc1") {
options.avcDecoderConfigRecord = desc
} else if (codec === "hev1") {
options.hevcDecoderConfigRecord = desc
} else if (codec === "Opus") {
// description is an identification header: https://datatracker.ietf.org/doc/html/rfc7845#section-5.1
// The first 8 bytes are the magic string "OpusHead", followed by what we actually want.
const dops = new MP4.BoxParser.dOpsBox(undefined)
// Annoyingly, the header is little endian while MP4 is big endian, so we have to parse.
const data = new MP4.Stream(desc, 8, MP4.Stream.LITTLE_ENDIAN)
dops.parse(data)
dops.Version = 0
options.description = dops
options.hdlr = "soun"
} else {
throw new Error(`unsupported codec: ${codec}`)
}
this.#track = this.#mp4.addTrack(options)
if (!this.#track) throw new Error("failed to initialize MP4 track")
const buffer = MP4.ISOFile.writeInitializationSegment(this.#mp4.ftyp!, this.#mp4.moov!, 0, 0)
const data = new Uint8Array(buffer)
controller.enqueue({
type: "init",
timestamp: 0,
duration: 0,
data,
})
}
#enqueue(frame: EncodedChunk, controller: TransformStreamDefaultController<Chunk>) {
// Check if we should create a new segment
if (frame.type == "key") {
this.#segment += 1
} else if (this.#segment == 0) {
throw new Error("must start with keyframe")
}
// We need a one frame buffer to compute the duration
if (!this.#frame) {
this.#frame = frame
return
}
const duration = frame.timestamp - this.#frame.timestamp
// TODO avoid this extra copy by writing to the mdat directly
// ...which means changing mp4box.js to take an offset instead of ArrayBuffer
const buffer = new Uint8Array(this.#frame.byteLength)
this.#frame.copyTo(buffer)
if (!this.#track) throw new Error("missing decoder config")
// Add the sample to the container
this.#mp4.addSample(this.#track, buffer, {
duration,
dts: this.#frame.timestamp,
cts: this.#frame.timestamp,
is_sync: this.#frame.type == "key",
})
const stream = new MP4.Stream(undefined, 0, MP4.Stream.BIG_ENDIAN)
// Moof and mdat atoms are written in pairs.
// TODO remove the moof/mdat from the Box to reclaim memory once everything works
for (;;) {
const moof = this.#mp4.moofs.shift()
const mdat = this.#mp4.mdats.shift()
if (!moof && !mdat) break
if (!moof) throw new Error("moof missing")
if (!mdat) throw new Error("mdat missing")
moof.write(stream)
mdat.write(stream)
}
// TODO avoid this extra copy by writing to the buffer provided in copyTo
const data = new Uint8Array(stream.buffer)
controller.enqueue({
type: this.#frame.type,
timestamp: this.#frame.timestamp,
duration: this.#frame.duration ?? 0,
data,
})
this.#frame = frame
}
/* TODO flush the last frame
#flush(controller: TransformStreamDefaultController<Chunk>) {
if (this.#frame) {
// TODO guess the duration
this.#enqueue(this.#frame, 0, controller)
}
}
*/
}
function isDecoderConfig(frame: DecoderConfig | EncodedChunk): frame is DecoderConfig {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return (frame as DecoderConfig).codec !== undefined
}
function isVideoConfig(frame: DecoderConfig): frame is VideoDecoderConfig {
return (frame as VideoDecoderConfig).codedWidth !== undefined
}

View File

@@ -1,10 +1,10 @@
import { Chunk } from "./chunk"
import type { Frame } from "../karp/frame"
export class Segment {
id: number
// Take in a stream of chunks
input: WritableStream<Chunk>
// Take in a stream of frames
input: WritableStream<Frame>
// Output a stream of bytes, which we fork for each new subscriber.
#cache: ReadableStream<Uint8Array>
@@ -16,16 +16,18 @@ export class Segment {
// Set a max size for each segment, dropping the tail if it gets too long.
// We tee the reader, so this limit applies to the FASTEST reader.
const backpressure = new ByteLengthQueuingStrategy({ highWaterMark: 8_000_000 })
const backpressure = new ByteLengthQueuingStrategy({
highWaterMark: 8_000_000,
})
const transport = new TransformStream<Chunk, Uint8Array>(
const transport = new TransformStream<Frame, Uint8Array>(
{
transform: (chunk: Chunk, controller) => {
transform: (frame: Frame, controller) => {
// Compute the max timestamp of the segment
this.timestamp = Math.max(chunk.timestamp + chunk.duration)
this.timestamp = Math.max(this.timestamp, frame.timestamp)
// Push the chunk to any listeners.
controller.enqueue(chunk.data)
controller.enqueue(frame.data)
},
},
undefined,

View File

@@ -1,9 +1,8 @@
import { Segment } from "./segment"
import { Notify } from "../common/async"
import { Chunk } from "./chunk"
import { Container } from "./container"
import { BroadcastConfig } from "./broadcast"
import type { BroadcastConfig } from "./broadcast"
import { Segment } from "./segment"
import type { Frame } from "../karp/frame"
import * as Audio from "./audio"
import * as Video from "./video"
@@ -36,7 +35,6 @@ export class Track {
async #runAudio(track: MediaStreamAudioTrack, config: AudioEncoderConfig) {
const source = new MediaStreamTrackProcessor({ track })
const encoder = new Audio.Encoder(config)
const container = new Container()
// Split the container at keyframe boundaries
const segments = new WritableStream({
@@ -45,13 +43,12 @@ export class Track {
abort: (e) => this.#close(e),
})
return source.readable.pipeThrough(encoder.frames).pipeThrough(container.encode).pipeTo(segments)
return source.readable.pipeThrough(encoder.frames).pipeTo(segments)
}
async #runVideo(track: MediaStreamVideoTrack, config: VideoEncoderConfig) {
const source = new MediaStreamTrackProcessor({ track })
const encoder = new Video.Encoder(config)
const container = new Container()
// Split the container at keyframe boundaries
const segments = new WritableStream({
@@ -60,18 +57,12 @@ export class Track {
abort: (e) => this.#close(e),
})
return source.readable.pipeThrough(encoder.frames).pipeThrough(container.encode).pipeTo(segments)
return source.readable.pipeThrough(encoder.frames).pipeTo(segments)
}
async #write(chunk: Chunk) {
if (chunk.type === "init") {
this.#init = chunk.data
this.#notify.wake()
return
}
async #write(frame: Frame) {
let current = this.#segments.at(-1)
if (!current || chunk.type === "key") {
if (!current || frame.type === "key") {
if (current) {
await current.input.close()
}
@@ -88,7 +79,7 @@ export class Track {
const first = this.#segments[0]
// Expire after 10s
if (chunk.timestamp - first.timestamp < 10_000_000) break
if (frame.timestamp - first.timestamp < 10_000_000) break
this.#segments.shift()
this.#offset += 1
@@ -99,7 +90,7 @@ export class Track {
const writer = current.input.getWriter()
if ((writer.desiredSize || 0) > 0) {
await writer.write(chunk)
await writer.write(frame)
} else {
console.warn("dropping chunk", writer.desiredSize)
}
@@ -147,7 +138,8 @@ export class Track {
if (this.#error) {
controller.error(this.#error)
return
} else if (this.#closed) {
}
if (this.#closed) {
controller.close()
return
}

View File

@@ -9,10 +9,10 @@
"path": "../common"
},
{
"path": "../transport"
"path": "../transfork"
},
{
"path": "../media"
"path": "../karp"
}
]
}

View File

@@ -1,3 +1,8 @@
import { Deferred } from "../common/async"
import type { Frame } from "../karp/frame"
import type { Group, Track } from "../transfork"
import { Closed } from "../transfork/error"
const SUPPORTED = [
"avc1", // H.264
"hev1", // HEVC (aka h.265)
@@ -8,10 +13,55 @@ export interface EncoderSupported {
codecs: string[]
}
export class Packer {
#source: MediaStreamTrackProcessor<VideoFrame>
#encoder: Encoder
#data: Track
#current?: Group
constructor(track: MediaStreamVideoTrack, encoder: Encoder, data: Track) {
this.#source = new MediaStreamTrackProcessor({ track })
this.#encoder = encoder
this.#data = data
}
async run() {
const output = new WritableStream({
write: (chunk) => this.#write(chunk),
close: () => this.#close(),
abort: (e) => this.#close(e),
})
return this.#source.readable.pipeThrough(this.#encoder.frames).pipeTo(output)
}
#write(frame: Frame) {
if (!this.#current || frame.type === "key") {
if (this.#current) {
this.#current.close()
}
this.#current = this.#data.appendGroup()
}
frame.encode(this.#current)
}
#close(err?: unknown) {
const closed = Closed.from(err)
if (this.#current) {
this.#current.close(closed)
}
this.#data.close(closed)
}
}
export class Encoder {
#encoder!: VideoEncoder
#encoderConfig: VideoEncoderConfig
#decoderConfig?: VideoDecoderConfig
#decoderConfig = new Deferred<VideoDecoderConfig>()
// true if we should insert a keyframe, undefined when the encoder should decide
#keyframeNext: true | undefined = true
@@ -20,7 +70,7 @@ export class Encoder {
#keyframeCounter = 0
// Converts raw rames to encoded frames.
frames: TransformStream<VideoFrame, VideoDecoderConfig | EncodedVideoChunk>
frames: TransformStream<VideoFrame, EncodedVideoChunk>
constructor(config: VideoEncoderConfig) {
config.bitrateMode ??= "constant"
@@ -53,12 +103,17 @@ export class Encoder {
return !!res.supported
}
async decoderConfig(): Promise<VideoDecoderConfig> {
return await this.#decoderConfig.promise
}
#start(controller: TransformStreamDefaultController<EncodedVideoChunk>) {
this.#encoder = new VideoEncoder({
output: (frame, metadata) => {
this.#enqueue(controller, frame, metadata)
},
error: (err) => {
this.#decoderConfig.reject(err)
throw err
},
})
@@ -77,23 +132,22 @@ export class Encoder {
}
#enqueue(
controller: TransformStreamDefaultController<VideoDecoderConfig | EncodedVideoChunk>,
controller: TransformStreamDefaultController<EncodedVideoChunk>,
frame: EncodedVideoChunk,
metadata?: EncodedVideoChunkMetadata,
) {
if (!this.#decoderConfig) {
if (this.#decoderConfig.pending) {
const config = metadata?.decoderConfig
if (!config) throw new Error("missing decoder config")
controller.enqueue(config)
this.#decoderConfig = config
this.#decoderConfig.resolve(config)
}
if (frame.type === "key") {
this.#keyframeCounter = 0
} else {
this.#keyframeCounter += 1
if (this.#keyframeCounter + this.#encoder.encodeQueueSize >= 2 * this.#encoderConfig.framerate!) {
const framesPerGop = this.#encoderConfig.framerate ? 2 * this.#encoderConfig.framerate : 60
if (this.#keyframeCounter + this.#encoder.encodeQueueSize >= framesPerGop) {
this.#keyframeNext = true
}
}

View File

@@ -0,0 +1,20 @@
import { type Track, decodeTrack } from "./track"
export interface Audio {
track: Track
codec: string
sample_rate: number
channel_count: number
bitrate?: number
}
export function decodeAudio(o: unknown): o is Audio {
if (typeof o !== "object" || o === null) return false
const obj = o as Partial<Audio>
if (!decodeTrack(obj.track)) return false
if (typeof obj.codec !== "string") return false
if (typeof obj.sample_rate !== "number") return false
if (typeof obj.channel_count !== "number") return false
return true
}

View File

@@ -0,0 +1,62 @@
import * as Transfork from "../../transfork"
import { type Audio, decodeAudio } from "./audio"
import { type Video, decodeVideo } from "./video"
export interface Broadcast {
path: string[]
video: Video[]
audio: Audio[]
}
export function encode(catalog: Broadcast): Uint8Array {
const encoder = new TextEncoder()
console.debug("encoding catalog", catalog)
const str = JSON.stringify(catalog)
return encoder.encode(str)
}
export function decode(path: string[], raw: Uint8Array): Broadcast {
const decoder = new TextDecoder()
const str = decoder.decode(raw)
const catalog = JSON.parse(str)
if (!decodeBroadcast(catalog)) {
console.error("invalid catalog", catalog)
throw new Error("invalid catalog")
}
catalog.path = path
return catalog
}
export async function fetch(connection: Transfork.Connection, path: string[]): Promise<Broadcast> {
const track = new Transfork.Track(path.concat("catalog.json"), 0)
const sub = await connection.subscribe(track)
try {
const segment = await sub.nextGroup()
if (!segment) throw new Error("no catalog data")
const frame = await segment.readFrame()
if (!frame) throw new Error("no catalog frame")
segment.close()
return decode(path, frame)
} finally {
sub.close()
}
}
export function decodeBroadcast(o: unknown): o is Broadcast {
if (typeof o !== "object" || o === null) return false
const catalog = o as Partial<Broadcast>
if (catalog.audio === undefined) catalog.audio = []
if (!Array.isArray(catalog.audio)) return false
if (!catalog.audio.every((track: unknown) => decodeAudio(track))) return false
if (catalog.video === undefined) catalog.video = []
if (!Array.isArray(catalog.video)) return false
if (!catalog.video.every((track: unknown) => decodeVideo(track))) return false
return true
}

View File

@@ -0,0 +1,7 @@
import type { Audio } from "./audio"
import { type Broadcast, decode, encode, fetch } from "./broadcast"
import type { Track } from "./track"
import type { Video } from "./video"
export type { Audio, Video, Track, Broadcast }
export { encode, decode, fetch }

View File

@@ -0,0 +1,15 @@
export type GroupOrder = "desc" | "asc"
export interface Track {
name: string
priority: number
}
export function decodeTrack(o: unknown): o is Track {
if (typeof o !== "object" || o === null) return false
const obj = o as Partial<Track>
if (typeof obj.name !== "string") return false
if (typeof obj.priority !== "number") return false
return true
}

View File

@@ -0,0 +1,29 @@
import * as Hex from "../../common/hex"
import { type Track, decodeTrack } from "./track"
export interface Video {
track: Track
codec: string
description?: Uint8Array
bitrate?: number
frame_rate?: number
resolution: Dimensions
}
export interface Dimensions {
width: number
height: number
}
export function decodeVideo(o: unknown): o is Video {
if (typeof o !== "object" || o === null) return false
const obj = o as Partial<Video>
if (!decodeTrack(obj.track)) return false
if (typeof obj.codec !== "string") return false
if (typeof obj.description !== "string") return false
obj.description = Hex.decode(obj.description)
return true
}

View File

@@ -0,0 +1,64 @@
import type { Group, GroupReader } from "../transfork/model"
import { setVint62 } from "../transfork/stream"
export type FrameType = "key" | "delta"
export class Frame {
type: FrameType
timestamp: number
data: Uint8Array
constructor(type: FrameType, timestamp: number, data: Uint8Array) {
this.type = type
this.timestamp = timestamp
this.data = data
}
static async decode(group: GroupReader): Promise<Frame | undefined> {
const kind = group.index === 0 ? "key" : "delta"
const payload = await group.readFrame()
if (!payload) {
return undefined
}
const [timestamp, data] = decode_timestamp(payload)
return new Frame(kind, timestamp, data)
}
encode(group: Group) {
if ((group.length === 0) !== (this.type === "key")) {
throw new Error(`invalid ${this.type} position`)
}
let frame = new Uint8Array(8 + this.data.byteLength)
const size = setVint62(frame, BigInt(this.timestamp)).byteLength
frame.set(this.data, size)
frame = new Uint8Array(frame.buffer, 0, this.data.byteLength + size)
group.writeFrame(frame)
}
}
// QUIC VarInt
function decode_timestamp(buf: Uint8Array): [number, Uint8Array] {
const size = 1 << ((buf[0] & 0xc0) >> 6)
const view = new DataView(buf.buffer, buf.byteOffset, size)
const remain = new Uint8Array(buf.buffer, buf.byteOffset + size, buf.byteLength - size)
let v: number
if (size === 1) {
v = buf[0] & 0x3f
} else if (size === 2) {
v = view.getInt16(0) & 0x3fff
} else if (size === 4) {
v = view.getUint32(0) & 0x3fffffff
} else if (size === 8) {
// NOTE: Precision loss above 2^52
v = Number(view.getBigUint64(0) & 0x3fffffffffffffffn)
} else {
throw new Error("impossible")
}
return [v, remain]
}

View File

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

View File

@@ -1,218 +0,0 @@
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

@@ -1,37 +0,0 @@
// 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

@@ -1,71 +0,0 @@
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

@@ -1,13 +0,0 @@
// 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

@@ -1,5 +1,5 @@
{
"name": "@nestri/moq",
"name": "@nestri/libmoq",
"type": "module",
"version": "0.1.4",
"description": "Media over QUIC library",
@@ -7,23 +7,14 @@
"repository": "github:kixelated/moq-js",
"scripts": {
"build": "tsc -b && cp ../LICENSE* ./dist && cp ./README.md ./dist && cp ./package.json ./dist",
"lint": "eslint .",
"fmt": "prettier --write ."
"check": "biome check",
"fix": "biome check --write"
},
"devDependencies": {
"@types/audioworklet": "^0.0.50",
"@types/dom-mediacapture-transform": "^0.1.6",
"@types/dom-webcodecs": "^0.1.8",
"@typescript/lib-dom": "npm:@types/web@^0.0.115",
"@typescript-eslint/eslint-plugin": "^6.4.0",
"@typescript-eslint/parser": "^6.4.0",
"eslint": "^8.47.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"prettier": "^3.0.1",
"typescript": "^5.1.6"
},
"dependencies": {
"mp4box": "^0.5.2"
}
}

View File

@@ -1,34 +1,57 @@
/// <reference types="vite/client" />
import * as Message from "./worker/message"
import { Ring, RingShared } from "../common/ring"
import type * as Catalog from "../karp/catalog"
import type { Frame } from "../karp/frame"
import type { Component } from "./timeline"
// This is a non-standard way of importing worklet/workers.
// Unfortunately, it's the only option because of a Vite bug: https://github.com/vitejs/vite/issues/11823
import workletURL from "./worklet/index.ts?worker&url"
// NOTE: This must be on the main thread
export class Audio {
context: AudioContext
worklet: Promise<AudioWorkletNode>
export class Renderer {
#context: AudioContext
#worklet: Promise<AudioWorkletNode>
constructor(config: Message.ConfigAudio) {
this.context = new AudioContext({
#ring: Ring
#ringShared: RingShared
#timeline: Component
#track: Catalog.Audio
#decoder!: AudioDecoder
#stream: TransformStream<Frame, AudioData>
constructor(track: Catalog.Audio, timeline: Component) {
this.#track = track
this.#context = new AudioContext({
latencyHint: "interactive",
sampleRate: config.sampleRate,
sampleRate: track.sample_rate,
})
this.worklet = this.load(config)
this.#worklet = this.load(track)
this.#timeline = timeline
this.#ringShared = new RingShared(2, track.sample_rate / 10) // 100ms
this.#ring = new Ring(this.#ringShared)
this.#stream = new TransformStream({
start: this.#start.bind(this),
transform: this.#transform.bind(this),
})
this.#run().catch((err) => console.error("failed to run audio renderer: ", err))
}
private async load(config: Message.ConfigAudio): Promise<AudioWorkletNode> {
private async load(catalog: Catalog.Audio): Promise<AudioWorkletNode> {
// Load the worklet source code.
await this.context.audioWorklet.addModule(workletURL)
await this.#context.audioWorklet.addModule(workletURL)
const volume = this.context.createGain()
const volume = this.#context.createGain()
volume.gain.value = 2.0
// Create the worklet
const worklet = new AudioWorkletNode(this.context, "renderer")
const worklet = new AudioWorkletNode(this.#context, "renderer")
worklet.port.addEventListener("message", this.on.bind(this))
worklet.onprocessorerror = (e: Event) => {
@@ -37,7 +60,13 @@ export class Audio {
// Connect the worklet to the volume node and then to the speakers
worklet.connect(volume)
volume.connect(this.context.destination)
volume.connect(this.#context.destination)
const config = {
sampleRate: catalog.sample_rate,
channelCount: catalog.channel_count,
ring: this.#ringShared,
}
worklet.port.postMessage({ config })
@@ -47,4 +76,58 @@ export class Audio {
private on(_event: MessageEvent) {
// TODO
}
play() {
this.#context.resume().catch((err) => console.warn("failed to resume audio context: ", err))
}
close() {
this.#context.close().catch((err) => console.warn("failed to close audio context: ", err))
}
#start(controller: TransformStreamDefaultController) {
this.#decoder = new AudioDecoder({
output: (frame: AudioData) => {
controller.enqueue(frame)
},
error: console.warn,
})
// We only support OPUS right now which doesn't need a description.
this.#decoder.configure({
codec: this.#track.codec,
sampleRate: this.#track.sample_rate,
numberOfChannels: this.#track.channel_count,
})
}
#transform(frame: Frame) {
const chunk = new EncodedAudioChunk({
type: frame.type,
timestamp: frame.timestamp,
data: frame.data,
})
this.#decoder.decode(chunk)
}
async #run() {
const reader = this.#timeline.frames.pipeThrough(this.#stream).getReader()
for (;;) {
const { value: frame, done } = await reader.read()
if (done) break
// Write audio samples to the ring buffer, dropping when there's no space.
const written = this.#ring.write(frame)
if (written < frame.numberOfFrames) {
/*
console.warn(
`droppped ${frame.numberOfFrames - written} audio samples`,
);
*/
}
}
}
}

View File

@@ -1,114 +0,0 @@
/// <reference types="vite/client" />
import * as Message from "./worker/message"
import { Audio } from "./audio"
import MediaWorker from "./worker?worker"
import { RingShared } from "../common/ring"
import { Root, isAudioTrack } from "../media/catalog"
import { GroupHeader } from "../transport/objects"
export interface PlayerConfig {
canvas: OffscreenCanvas
catalog: Root
}
// This is a non-standard way of importing worklet/workers.
// Unfortunately, it's the only option because of a Vite bug: https://github.com/vitejs/vite/issues/11823
// Responsible for sending messages to the worker and worklet.
export default class Backend {
// General worker
#worker: Worker
// The audio context, which must be created on the main thread.
#audio?: Audio
constructor(config: PlayerConfig) {
// TODO does this block the main thread? If so, make this async
// @ts-expect-error: The Vite typing is wrong https://github.com/vitejs/vite/blob/22bd67d70a1390daae19ca33d7de162140d533d6/packages/vite/client.d.ts#L182
this.#worker = new MediaWorker({ format: "es" })
this.#worker.addEventListener("message", this.on.bind(this))
let sampleRate: number | undefined
let channels: number | undefined
for (const track of config.catalog.tracks) {
if (isAudioTrack(track)) {
if (sampleRate && track.selectionParams.samplerate !== sampleRate) {
throw new Error(`TODO multiple audio tracks with different sample rates`)
}
sampleRate = track.selectionParams.samplerate
// TODO properly handle weird channel configs
channels = Math.max(+track.selectionParams.channelConfig, channels ?? 0)
}
}
const msg: Message.Config = {}
// Only configure audio is we have an audio track
if (sampleRate && channels) {
msg.audio = {
channels: channels,
sampleRate: sampleRate,
ring: new RingShared(2, sampleRate / 10), // 100ms
}
this.#audio = new Audio(msg.audio)
}
// TODO only send the canvas if we have a video track
msg.video = {
canvas: config.canvas,
}
this.send({ config: msg }, msg.video.canvas)
}
async play() {
await this.#audio?.context.resume()
}
init(init: Init) {
this.send({ init })
}
segment(segment: Segment) {
this.send({ segment }, segment.stream)
}
async close() {
this.#worker.terminate()
await this.#audio?.context.close()
}
// Enforce we're sending valid types to the worker
private send(msg: Message.ToWorker, ...transfer: Transferable[]) {
//console.log("sent message from main to worker", msg)
this.#worker.postMessage(msg, transfer)
}
private on(e: MessageEvent) {
const msg = e.data as Message.FromWorker
// Don't print the verbose timeline message.
if (!msg.timeline) {
//console.log("received message from worker to main", msg)
}
}
}
export interface Init {
name: string // name of the init track
data: Uint8Array
}
export interface Segment {
init: string // name of the init track
kind: "audio" | "video"
header: GroupHeader
buffer: Uint8Array
stream: ReadableStream<Uint8Array>
}

View File

@@ -0,0 +1,148 @@
import type * as Catalog from "../karp/catalog"
import type { Connection } from "../transfork/connection"
import { Track } from "../transfork"
import { Frame } from "../karp/frame"
import type { GroupReader } from "../transfork/model"
import * as Audio from "./audio"
import { Timeline } from "./timeline"
import * as Video from "./video"
// This class must be created on the main thread due to AudioContext.
export class Broadcast {
#connection: Connection
#catalog: Catalog.Broadcast
// Running is a promise that resolves when the player is closed.
// #close is called with no error, while #abort is called with an error.
#running: Promise<void>
// Timeline receives samples, buffering them and choosing the timestamp to render.
#timeline = new Timeline()
#audio?: Audio.Renderer
#video?: Video.Renderer
constructor(connection: Connection, catalog: Catalog.Broadcast, canvas: HTMLCanvasElement) {
this.#connection = connection
this.#catalog = catalog
const running = []
// Only configure audio is we have an audio track
const audio = (catalog.audio || []).at(0)
if (audio) {
this.#audio = new Audio.Renderer(audio, this.#timeline.audio)
running.push(this.#runAudio(audio))
}
const video = (catalog.video || []).at(0)
if (video) {
this.#video = new Video.Renderer(video, canvas, this.#timeline.video)
running.push(this.#runVideo(video))
}
// Async work
this.#running = Promise.race([...running])
}
async #runAudio(audio: Catalog.Audio) {
const track = new Track(this.#catalog.path.concat(audio.track.name), audio.track.priority)
const sub = await this.#connection.subscribe(track)
try {
for (;;) {
const group = await Promise.race([sub.nextGroup(), this.#running])
if (!group) break
this.#runAudioGroup(audio, group)
.catch(() => {})
.finally(() => group.close())
}
} finally {
sub.close()
}
}
async #runVideo(video: Catalog.Video) {
const track = new Track(this.#catalog.path.concat(video.track.name), video.track.priority)
const sub = await this.#connection.subscribe(track)
try {
for (;;) {
const group = await Promise.race([sub.nextGroup(), this.#running])
if (!group) break
this.#runVideoGroup(video, group)
.catch(() => {})
.finally(() => group.close())
}
} finally {
sub.close()
}
}
async #runAudioGroup(audio: Catalog.Audio, group: GroupReader) {
const timeline = this.#timeline.audio
// Create a queue that will contain each frame
const queue = new TransformStream<Frame>({})
const segment = queue.writable.getWriter()
// Add the segment to the timeline
const segments = timeline.segments.getWriter()
await segments.write({
sequence: group.id,
frames: queue.readable,
})
segments.releaseLock()
// Read each chunk, decoding the MP4 frames and adding them to the queue.
for (;;) {
const frame = await Frame.decode(group)
if (!frame) break
await segment.write(frame)
}
// We done.
await segment.close()
}
async #runVideoGroup(video: Catalog.Video, group: GroupReader) {
const timeline = this.#timeline.video
// Create a queue that will contain each MP4 frame.
const queue = new TransformStream<Frame>({})
const segment = queue.writable.getWriter()
// Add the segment to the timeline
const segments = timeline.segments.getWriter()
await segments.write({
sequence: group.id,
frames: queue.readable,
})
segments.releaseLock()
for (;;) {
const frame = await Frame.decode(group)
if (!frame) break
await segment.write(frame)
}
// We done.
await segment.close()
}
unmute() {
console.debug("unmuting audio")
this.#audio?.play()
}
close() {
this.#audio?.close()
this.#video?.close()
}
}

View File

@@ -1,190 +1,2 @@
import * as Message from "./worker/message"
import { Connection } from "../transport/connection"
import * as Catalog from "../media/catalog"
import { asError } from "../common/error"
import Backend from "./backend"
import { Client } from "../transport/client"
import { GroupReader } from "../transport/objects"
export type Range = Message.Range
export type Timeline = Message.Timeline
export interface PlayerConfig {
url: string
namespace: string
fingerprint?: string // URL to fetch TLS certificate fingerprint
canvas: HTMLCanvasElement
}
// This class must be created on the main thread due to AudioContext.
export class Player {
#backend: Backend
// A periodically updated timeline
//#timeline = new Watch<Timeline | undefined>(undefined)
#connection: Connection
#catalog: Catalog.Root
// Running is a promise that resolves when the player is closed.
// #close is called with no error, while #abort is called with an error.
#running: Promise<void>
#close!: () => void
#abort!: (err: Error) => void
private constructor(connection: Connection, catalog: Catalog.Root, backend: Backend) {
this.#connection = connection
this.#catalog = catalog
this.#backend = backend
const abort = new Promise<void>((resolve, reject) => {
this.#close = resolve
this.#abort = reject
})
// Async work
this.#running = Promise.race([this.#run(), abort]).catch(this.#close)
}
static async create(config: PlayerConfig): Promise<Player> {
const client = new Client({ url: config.url, fingerprint: config.fingerprint, role: "subscriber" })
const connection = await client.connect()
const catalog = await Catalog.fetch(connection, config.namespace)
console.log("catalog", catalog)
const canvas = config.canvas.transferControlToOffscreen()
const backend = new Backend({ canvas, catalog })
return new Player(connection, catalog, backend)
}
async #run() {
const inits = new Set<[string, string]>()
const tracks = new Array<Catalog.Track>()
for (const track of this.#catalog.tracks) {
if (!track.namespace) throw new Error("track has no namespace")
if (track.initTrack) inits.add([track.namespace, track.initTrack])
tracks.push(track)
}
// Call #runInit on each unique init track
// TODO do this in parallel with #runTrack to remove a round trip
await Promise.all(Array.from(inits).map((init) => this.#runInit(...init)))
// Call #runTrack on each track
await Promise.all(tracks.map((track) => this.#runTrack(track)))
}
async #runInit(namespace: string, name: string) {
const sub = await this.#connection.subscribe(namespace, name)
try {
const init = await Promise.race([sub.data(), this.#running])
if (!init) throw new Error("no init data")
// We don't care what type of reader we get, we just want the payload.
const chunk = await init.read()
if (!chunk) throw new Error("no init chunk")
if (!(chunk.payload instanceof Uint8Array)) throw new Error("invalid init chunk")
this.#backend.init({ data: chunk.payload, name })
} finally {
await sub.close()
}
}
async #runTrack(track: Catalog.Track) {
if (!track.namespace) throw new Error("track has no namespace")
const sub = await this.#connection.subscribe(track.namespace, track.name)
try {
for (;;) {
const segment = await Promise.race([sub.data(), this.#running])
if (!segment) break
if (!(segment instanceof GroupReader)) {
throw new Error(`expected group reader for segment: ${track.name}`)
}
const kind = Catalog.isVideoTrack(track) ? "video" : Catalog.isAudioTrack(track) ? "audio" : "unknown"
if (kind == "unknown") {
throw new Error(`unknown track kind: ${track.name}`)
}
if (!track.initTrack) {
throw new Error(`no init track for segment: ${track.name}`)
}
const [buffer, stream] = segment.stream.release()
this.#backend.segment({
init: track.initTrack,
kind,
header: segment.header,
buffer,
stream,
})
}
} catch (error) {
console.error("Error in #runTrack:", error)
} finally {
await sub.close()
}
}
getCatalog() {
return this.#catalog
}
#onMessage(msg: Message.FromWorker) {
if (msg.timeline) {
//this.#timeline.update(msg.timeline)
}
}
async close(err?: Error) {
if (err) this.#abort(err)
else this.#close()
if (this.#connection) this.#connection.close()
if (this.#backend) await this.#backend.close()
}
async closed(): Promise<Error | undefined> {
try {
await this.#running
} catch (e) {
return asError(e)
}
}
/*
play() {
this.#backend.play({ minBuffer: 0.5 }) // TODO configurable
}
seek(timestamp: number) {
this.#backend.seek({ timestamp })
}
*/
async play() {
await this.#backend.play()
}
/*
async *timeline() {
for (;;) {
const [timeline, next] = this.#timeline.value()
if (timeline) yield timeline
if (!next) break
await next
}
}
*/
}
export { Player } from "./player"
export type { PlayerConfig } from "./player"

View File

@@ -0,0 +1,63 @@
import * as Catalog from "../karp/catalog"
import type { Connection } from "../transfork/connection"
import { Broadcast } from "./broadcast"
export interface PlayerConfig {
connection: Connection
path: string[]
canvas: HTMLCanvasElement
}
// This class must be created on the main thread due to AudioContext.
export class Player {
#config: PlayerConfig
#running: Promise<void>
#active?: Broadcast
constructor(config: PlayerConfig) {
this.#config = config
this.#running = this.#run()
}
async #run() {
const announced = await this.#config.connection.announced(this.#config.path)
let activeId = -1
for (;;) {
const announce = await announced.next()
if (!announce) break
if (announce.path.length === this.#config.path.length) {
throw new Error("expected resumable broadcast")
}
const path = announce.path.slice(0, this.#config.path.length + 1)
const id = Number.parseInt(path[path.length - 1])
if (id <= activeId) continue
const catalog = await Catalog.fetch(this.#config.connection, path)
this.#active?.close()
this.#active = new Broadcast(this.#config.connection, catalog, this.#config.canvas)
activeId = id
}
this.#active?.close()
}
close() {
this.#config.connection.close()
this.#active?.close()
this.#active = undefined
}
async closed() {
await Promise.any([this.#running, this.#config.connection.closed()])
}
unmute() {
this.#active?.unmute()
}
}

View File

@@ -1,5 +1,4 @@
import type { Frame } from "../../media/mp4"
export type { Frame }
import type { Frame } from "../karp/frame"
export interface Range {
start: number
@@ -48,7 +47,7 @@ export class Component {
// Get the next segment to render.
const segments = this.#segments.readable.getReader()
let res
let res: ReadableStreamReadResult<Segment> | ReadableStreamReadResult<Frame>
if (this.#current) {
// Get the next frame to render.
const frames = this.#current.frames.getReader()
@@ -85,17 +84,17 @@ export class Component {
// Our segment is older than the current, abandon it.
await value.frames.cancel("skipping segment; too old")
continue
} else {
// Our segment is newer than the current, cancel the old one.
await this.#current.frames.cancel("skipping segment; too slow")
}
// Our segment is newer than the current, cancel the old one.
await this.#current.frames.cancel("skipping segment; too slow")
}
this.#current = value
}
}
async #cancel(reason: any) {
async #cancel(reason: Error) {
if (this.#current) {
await this.#current.frames.cancel(reason)
}
@@ -111,8 +110,6 @@ export class Component {
}
// Return if a type is a segment or frame
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
function isSegment(value: Segment | Frame): value is Segment {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return (value as Segment).frames !== undefined
}

View File

@@ -10,10 +10,10 @@
"path": "../common"
},
{
"path": "../transport"
"path": "../transfork"
},
{
"path": "../media"
"path": "../karp"
}
],
"paths": {

View File

@@ -1,16 +1,18 @@
import { Frame, Component } from "./timeline"
import * as MP4 from "../../media/mp4"
import * as Message from "./message"
import type * as Catalog from "../karp/catalog"
import type { Frame } from "../karp/frame"
import type { Component } from "./timeline"
export class Renderer {
#canvas: OffscreenCanvas
#track: Catalog.Video
#canvas: HTMLCanvasElement
#timeline: Component
#decoder!: VideoDecoder
#queue: TransformStream<Frame, VideoFrame>
constructor(config: Message.ConfigVideo, timeline: Component) {
this.#canvas = config.canvas
constructor(track: Catalog.Video, canvas: HTMLCanvasElement, timeline: Component) {
this.#track = track
this.#canvas = canvas
this.#timeline = timeline
this.#queue = new TransformStream({
@@ -18,7 +20,11 @@ export class Renderer {
transform: this.#transform.bind(this),
})
this.#run().catch(console.error)
this.#run().catch((err) => console.error("failed to run video renderer: ", err))
}
close() {
// TODO
}
async #run() {
@@ -47,36 +53,21 @@ export class Renderer {
},
error: console.error,
})
this.#decoder.configure({
codec: this.#track.codec,
codedHeight: this.#track.resolution.height,
codedWidth: this.#track.resolution.width,
description: this.#track.description,
optimizeForLatency: true,
})
}
#transform(frame: Frame) {
// Configure the decoder with the first frame
if (this.#decoder.state !== "configured") {
const { sample, track } = frame
const desc = sample.description
const box = desc.avcC ?? desc.hvcC ?? desc.vpcC ?? desc.av1C
if (!box) throw new Error(`unsupported codec: ${track.codec}`)
const buffer = new MP4.Stream(undefined, 0, MP4.Stream.BIG_ENDIAN)
box.write(buffer)
const description = new Uint8Array(buffer.buffer, 8) // Remove the box header.
if (!MP4.isVideoTrack(track)) throw new Error("expected video track")
this.#decoder.configure({
codec: track.codec,
codedHeight: track.video.height,
codedWidth: track.video.width,
description,
// optimizeForLatency: true
})
}
const chunk = new EncodedVideoChunk({
type: frame.sample.is_sync ? "key" : "delta",
data: frame.sample.data,
timestamp: frame.sample.dts / frame.track.timescale,
type: frame.type,
data: frame.data,
timestamp: frame.timestamp,
})
this.#decoder.decode(chunk)

View File

@@ -1,73 +0,0 @@
import * as Message from "./message"
import { Ring } from "../../common/ring"
import { Component, Frame } from "./timeline"
import * as MP4 from "../../media/mp4"
// This is run in a worker.
export class Renderer {
#ring: Ring
#timeline: Component
#decoder!: AudioDecoder
#stream: TransformStream<Frame, AudioData>
constructor(config: Message.ConfigAudio, timeline: Component) {
this.#timeline = timeline
this.#ring = new Ring(config.ring)
this.#stream = new TransformStream({
start: this.#start.bind(this),
transform: this.#transform.bind(this),
})
this.#run().catch(console.error)
}
#start(controller: TransformStreamDefaultController) {
this.#decoder = new AudioDecoder({
output: (frame: AudioData) => {
controller.enqueue(frame)
},
error: console.warn,
})
}
#transform(frame: Frame) {
if (this.#decoder.state !== "configured") {
const track = frame.track
if (!MP4.isAudioTrack(track)) throw new Error("expected audio track")
// We only support OPUS right now which doesn't need a description.
this.#decoder.configure({
codec: track.codec,
sampleRate: track.audio.sample_rate,
numberOfChannels: track.audio.channel_count,
})
}
const chunk = new EncodedAudioChunk({
type: frame.sample.is_sync ? "key" : "delta",
timestamp: frame.sample.dts / frame.track.timescale,
duration: frame.sample.duration,
data: frame.sample.data,
})
this.#decoder.decode(chunk)
}
async #run() {
const reader = this.#timeline.frames.pipeThrough(this.#stream).getReader()
for (;;) {
const { value: frame, done } = await reader.read()
if (done) break
// Write audio samples to the ring buffer, dropping when there's no space.
const written = this.#ring.write(frame)
if (written < frame.numberOfFrames) {
console.warn(`droppped ${frame.numberOfFrames - written} audio samples`)
}
}
}
}

View File

@@ -1,119 +0,0 @@
import { Timeline } from "./timeline"
import * as Audio from "./audio"
import * as Video from "./video"
import * as MP4 from "../../media/mp4"
import * as Message from "./message"
import { asError } from "../../common/error"
import { Deferred } from "../../common/async"
import { GroupReader, Reader } from "../../transport/objects"
class Worker {
// Timeline receives samples, buffering them and choosing the timestamp to render.
#timeline = new Timeline()
// A map of init tracks.
#inits = new Map<string, Deferred<Uint8Array>>()
// Renderer requests samples, rendering video frames and emitting audio frames.
#audio?: Audio.Renderer
#video?: Video.Renderer
on(e: MessageEvent) {
const msg = e.data as Message.ToWorker
if (msg.config) {
this.#onConfig(msg.config)
} else if (msg.init) {
// TODO buffer the init segmnet so we don't hold the stream open.
this.#onInit(msg.init)
} else if (msg.segment) {
this.#onSegment(msg.segment).catch(console.warn)
} else {
throw new Error(`unknown message: + ${JSON.stringify(msg)}`)
}
}
#onConfig(msg: Message.Config) {
if (msg.audio) {
this.#audio = new Audio.Renderer(msg.audio, this.#timeline.audio)
}
if (msg.video) {
this.#video = new Video.Renderer(msg.video, this.#timeline.video)
}
}
#onInit(msg: Message.Init) {
let init = this.#inits.get(msg.name)
if (!init) {
init = new Deferred()
this.#inits.set(msg.name, init)
}
init.resolve(msg.data)
}
async #onSegment(msg: Message.Segment) {
let init = this.#inits.get(msg.init)
if (!init) {
init = new Deferred()
this.#inits.set(msg.init, init)
}
// Create a new stream that we will use to decode.
const container = new MP4.Parser(await init.promise)
const timeline = msg.kind === "audio" ? this.#timeline.audio : this.#timeline.video
const reader = new GroupReader(msg.header, new Reader(msg.buffer, msg.stream))
// Create a queue that will contain each MP4 frame.
const queue = new TransformStream<MP4.Frame>({})
const segment = queue.writable.getWriter()
// Add the segment to the timeline
const segments = timeline.segments.getWriter()
await segments.write({
sequence: msg.header.group,
frames: queue.readable,
})
segments.releaseLock()
// Read each chunk, decoding the MP4 frames and adding them to the queue.
for (;;) {
const chunk = await reader.read()
if (!chunk) {
break
}
if (!(chunk.payload instanceof Uint8Array)) {
throw new Error(`invalid payload: ${chunk.payload}`)
}
const frames = container.decode(chunk.payload)
for (const frame of frames) {
await segment.write(frame)
}
}
// We done.
await segment.close()
}
}
// Pass all events to the worker
const worker = new Worker()
self.addEventListener("message", (msg) => {
try {
worker.on(msg)
} catch (e) {
const err = asError(e)
console.warn("worker error:", err)
}
})
// Validates this is an expected message
function _send(msg: Message.FromWorker) {
postMessage(msg)
}

View File

@@ -1,98 +0,0 @@
import { GroupHeader } from "../../transport/objects"
import { RingShared } from "../../common/ring"
export interface Config {
audio?: ConfigAudio
video?: ConfigVideo
}
export interface ConfigAudio {
channels: number
sampleRate: number
ring: RingShared
}
export interface ConfigVideo {
canvas: OffscreenCanvas
}
export interface Init {
name: string // name of the init object
data: Uint8Array
}
export interface Segment {
init: string // name of the init object
kind: "audio" | "video"
header: GroupHeader
buffer: Uint8Array
stream: ReadableStream<Uint8Array>
}
/*
export interface Play {
// Start playback once the minimum buffer size has been reached.
minBuffer: number
}
export interface Seek {
timestamp: number
}
*/
// Sent periodically with the current timeline info.
export interface Timeline {
// The current playback position
timestamp?: number
// Audio specific information
audio: TimelineAudio
// Video specific information
video: TimelineVideo
}
export interface TimelineAudio {
buffer: Range[]
}
export interface TimelineVideo {
buffer: Range[]
}
export interface Range {
start: number
end: number
}
// Used to validate that only the correct messages can be sent.
// Any top level messages that can be sent to the worker.
export interface ToWorker {
// Sent to configure on startup.
config?: Config
// Sent on each init/data stream
init?: Init
segment?: Segment
/*
// Sent to control playback
play?: Play
seek?: Seek
*/
}
// Any top-level messages that can be sent from the worker.
export interface FromWorker {
// Sent back to the main thread regularly to update the UI
timeline?: Timeline
}
/*
interface ToWorklet {
config?: Audio.Config
}
*/

View File

@@ -1,6 +1,6 @@
// TODO add support for @/ to avoid relative imports
import { Ring } from "../../common/ring"
import * as Message from "./message"
import type * as Message from "./message"
class Renderer extends AudioWorkletProcessor {
ring?: Ring
@@ -26,17 +26,17 @@ class Renderer extends AudioWorkletProcessor {
}
// Inputs and outputs in groups of 128 samples.
process(inputs: Float32Array[][], outputs: Float32Array[][], _parameters: Record<string, Float32Array>): boolean {
process(_inputs: Float32Array[][], outputs: Float32Array[][], _parameters: Record<string, Float32Array>): boolean {
if (!this.ring) {
// Paused
return true
}
if (inputs.length != 1 && outputs.length != 1) {
if (outputs.length !== 1) {
throw new Error("only a single track is supported")
}
if (this.ring.size() == this.ring.capacity) {
if (this.ring.size() === this.ring.capacity) {
// This is a hack to clear any latency in the ring buffer.
// The proper solution is to play back slightly faster?
console.warn("resyncing ring buffer")

View File

@@ -1,4 +1,4 @@
import { RingShared } from "../../common/ring"
import type { RingShared } from "../../common/ring"
export interface From {
config?: Config
@@ -7,6 +7,5 @@ export interface From {
export interface Config {
channels: number
sampleRate: number
ring: RingShared
}

View File

@@ -1,15 +1,11 @@
import * as Stream from "./stream"
import * as Setup from "./setup"
import * as Control from "./control"
import { Objects } from "./objects"
import * as Hex from "../common/hex"
import { Connection } from "./connection"
import * as Message from "./message"
import { Stream } from "./stream"
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
@@ -39,28 +35,17 @@ export class Client {
const quic = new WebTransport(this.config.url, options)
await quic.ready
const stream = await quic.createBidirectionalStream()
const client = new Message.SessionClient([Message.Version.FORK_02])
const stream = await Stream.open(quic, client)
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) {
const server = await Message.SessionServer.decode(stream.reader)
if (server.version !== Message.Version.FORK_02) {
throw new Error(`unsupported server version: ${server.version}`)
}
const control = new Control.Stream(reader, writer)
const objects = new Objects(quic)
console.log(`established connection: version=${server.version}`)
return new Connection(quic, control, objects)
return new Connection(quic, stream)
}
async #fetchFingerprint(url?: string): Promise<WebTransportHash | undefined> {
@@ -68,16 +53,11 @@ export class Client {
// 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)
}
const bytes = Hex.decode(await response.text())
return {
algorithm: "sha-256",
value: hexBytes,
value: bytes,
}
}
}

View File

@@ -0,0 +1,158 @@
import { asError } from "../common/error"
import * as Message from "./message"
import { Reader, Stream } from "./stream"
import type { Queue } from "../common/async"
import { Closed } from "./error"
import type { Track, TrackReader } from "./model"
import { Publisher } from "./publisher"
import { type Announced, Subscriber } from "./subscriber"
export class Connection {
// The established WebTransport session.
#quic: WebTransport
// Use to receive/send session messages.
#session: Stream
// Module for contributing tracks.
#publisher: Publisher
// Module for distributing tracks.
#subscriber: Subscriber
// Async work running in the background
#running: Promise<void>
constructor(quic: WebTransport, session: Stream) {
this.#quic = quic
this.#session = session
this.#publisher = new Publisher(this.#quic)
this.#subscriber = new Subscriber(this.#quic)
this.#running = this.#run()
}
close(code = 0, reason = "") {
this.#quic.close({ closeCode: code, reason })
}
async #run(): Promise<void> {
const session = this.#runSession().catch((err) => new Error("failed to run session: ", err))
const bidis = this.#runBidis().catch((err) => new Error("failed to run bidis: ", err))
const unis = this.#runUnis().catch((err) => new Error("failed to run unis: ", err))
await Promise.all([session, bidis, unis])
}
publish(track: TrackReader) {
this.#publisher.publish(track)
}
async announced(prefix: string[] = []): Promise<Queue<Announced>> {
return this.#subscriber.announced(prefix)
}
async subscribe(track: Track): Promise<TrackReader> {
return await this.#subscriber.subscribe(track)
}
async #runSession() {
// Receive messages until the connection is closed.
for (;;) {
const msg = await Message.SessionInfo.decode_maybe(this.#session.reader)
if (!msg) break
// TODO use the session info
}
}
async #runBidis() {
for (;;) {
const next = await Stream.accept(this.#quic)
if (!next) {
break
}
const [msg, stream] = next
this.#runBidi(msg, stream).catch((err) => stream.writer.reset(Closed.extract(err)))
}
}
async #runBidi(msg: Message.Bi, stream: Stream) {
console.debug("received bi stream: ", msg)
if (msg instanceof Message.SessionClient) {
throw new Error("duplicate session stream")
}
if (msg instanceof Message.AnnounceInterest) {
if (!this.#subscriber) {
throw new Error("not a subscriber")
}
return await this.#publisher.runAnnounce(msg, stream)
}
if (msg instanceof Message.Subscribe) {
if (!this.#publisher) {
throw new Error("not a publisher")
}
return await this.#publisher.runSubscribe(msg, stream)
}
if (msg instanceof Message.Datagrams) {
if (!this.#publisher) {
throw new Error("not a publisher")
}
return await this.#publisher.runDatagrams(msg, stream)
}
if (msg instanceof Message.Fetch) {
if (!this.#publisher) {
throw new Error("not a publisher")
}
return await this.#publisher.runFetch(msg, stream)
}
if (msg instanceof Message.InfoRequest) {
if (!this.#publisher) {
throw new Error("not a publisher")
}
return await this.#publisher.runInfo(msg, stream)
}
}
async #runUnis() {
for (;;) {
const next = await Reader.accept(this.#quic)
if (!next) {
break
}
const [msg, stream] = next
this.#runUni(msg, stream).catch((err) => stream.stop(Closed.extract(err)))
}
}
async #runUni(msg: Message.Uni, stream: Reader) {
console.debug("received uni stream: ", msg)
if (msg instanceof Message.Group) {
if (!this.#subscriber) {
throw new Error("not a subscriber")
}
return this.#subscriber.runGroup(msg, stream)
}
}
async closed(): Promise<Error> {
try {
await this.#running
return new Error("closed")
} catch (e) {
return asError(e)
}
}
}

View File

@@ -0,0 +1,20 @@
export class Closed extends Error {
readonly code?: number
constructor(code?: number) {
super(`closed code=${code}`)
this.code = code
}
static from(err: unknown): Closed {
return new Closed(Closed.extract(err))
}
static extract(err: unknown): number {
if (err instanceof WebTransportError && err.streamErrorCode !== null) {
return err.streamErrorCode
}
return 0
}
}

View File

@@ -0,0 +1,45 @@
import type { Reader, Writer } from "./stream"
export class FrameReader {
#stream: Reader
constructor(stream: Reader) {
this.#stream = stream
}
// Returns the next frame
async read(): Promise<Uint8Array | undefined> {
if (await this.#stream.done()) return
const size = await this.#stream.u53()
const payload = await this.#stream.read(size)
return payload
}
async stop(code: number) {
await this.#stream.stop(code)
}
}
export class FrameWriter {
#stream: Writer
constructor(stream: Writer) {
this.#stream = stream
}
// Writes the next frame
async write(payload: Uint8Array) {
await this.#stream.u53(payload.byteLength)
await this.#stream.write(payload)
}
async close() {
await this.#stream.close()
}
async reset(code: number) {
await this.#stream.reset(code)
}
}

View File

@@ -3,5 +3,5 @@ export type { ClientConfig } from "./client"
export { Connection } from "./connection"
export { SubscribeRecv, AnnounceSend } from "./publisher"
export { AnnounceRecv, SubscribeSend } from "./subscriber"
export { Track, Group } from "./model"
export { Announced } from "./subscriber"

View File

@@ -0,0 +1,428 @@
import type { Reader, Writer } from "./stream"
export enum Version {
DRAFT_00 = 0xff000000,
DRAFT_01 = 0xff000001,
DRAFT_02 = 0xff000002,
DRAFT_03 = 0xff000003,
FORK_00 = 0xff0bad00,
FORK_01 = 0xff0bad01,
FORK_02 = 0xff0bad02,
}
export class Extensions {
entries: Map<bigint, Uint8Array>
constructor() {
this.entries = new Map()
}
set(id: bigint, value: Uint8Array) {
this.entries.set(id, value)
}
get(id: bigint): Uint8Array | undefined {
return this.entries.get(id)
}
remove(id: bigint): Uint8Array | undefined {
const value = this.entries.get(id)
this.entries.delete(id)
return value
}
async encode(w: Writer) {
await w.u53(this.entries.size)
for (const [id, value] of this.entries) {
await w.u62(id)
await w.u53(value.length)
await w.write(value)
}
}
static async decode(r: Reader): Promise<Extensions> {
const count = await r.u53()
const params = new Extensions()
for (let i = 0; i < count; i++) {
const id = await r.u62()
const size = await r.u53()
const value = await r.read(size)
if (params.entries.has(id)) {
throw new Error(`duplicate parameter id: ${id}`)
}
params.entries.set(id, value)
}
return params
}
}
export enum Order {
Any = 0,
Ascending = 1,
Descending = 2,
}
export class SessionClient {
versions: Version[]
extensions: Extensions
static StreamID = 0x0
constructor(versions: Version[], extensions = new Extensions()) {
this.versions = versions
this.extensions = extensions
}
async encode(w: Writer) {
await w.u53(this.versions.length)
for (const v of this.versions) {
await w.u53(v)
}
await this.extensions.encode(w)
}
static async decode(r: Reader): Promise<SessionClient> {
const versions = []
const count = await r.u53()
for (let i = 0; i < count; i++) {
versions.push(await r.u53())
}
const extensions = await Extensions.decode(r)
return new SessionClient(versions, extensions)
}
}
export class SessionServer {
version: Version
extensions: Extensions
constructor(version: Version, extensions = new Extensions()) {
this.version = version
this.extensions = extensions
}
async encode(w: Writer) {
await w.u53(this.version)
await this.extensions.encode(w)
}
static async decode(r: Reader): Promise<SessionServer> {
const version = await r.u53()
const extensions = await Extensions.decode(r)
return new SessionServer(version, extensions)
}
}
export class SessionInfo {
bitrate: number
constructor(bitrate: number) {
this.bitrate = bitrate
}
async encode(w: Writer) {
await w.u53(this.bitrate)
}
static async decode(r: Reader): Promise<SessionInfo> {
const bitrate = await r.u53()
return new SessionInfo(bitrate)
}
static async decode_maybe(r: Reader): Promise<SessionInfo | undefined> {
if (await r.done()) return
return await SessionInfo.decode(r)
}
}
export type AnnounceStatus = "active" | "closed"
export class Announce {
suffix: string[]
status: AnnounceStatus
constructor(suffix: string[], status: AnnounceStatus) {
this.suffix = suffix
this.status = status
}
async encode(w: Writer) {
await w.u53(this.status === "active" ? 1 : 0)
await w.path(this.suffix)
}
static async decode(r: Reader): Promise<Announce> {
const status = (await r.u53()) === 1 ? "active" : "closed"
const suffix = await r.path()
return new Announce(suffix, status)
}
static async decode_maybe(r: Reader): Promise<Announce | undefined> {
if (await r.done()) return
return await Announce.decode(r)
}
}
export class AnnounceInterest {
static StreamID = 0x1
constructor(public prefix: string[]) {}
async encode(w: Writer) {
await w.path(this.prefix)
}
static async decode(r: Reader): Promise<AnnounceInterest> {
const prefix = await r.path()
return new AnnounceInterest(prefix)
}
}
export class SubscribeUpdate {
priority: number
order = Order.Any
expires = 0 // ms
start?: bigint
end?: bigint
constructor(priority: number) {
this.priority = priority
}
async encode(w: Writer) {
await w.u53(this.priority)
await w.u53(this.order)
await w.u53(this.expires)
await w.u62(this.start ? this.start + 1n : 0n)
await w.u62(this.end ? this.end + 1n : 0n)
}
static async decode(r: Reader): Promise<SubscribeUpdate> {
const priority = await r.u53()
const order = await r.u53()
if (order > 2) {
throw new Error(`invalid order: ${order}`)
}
const expires = await r.u53()
const start = await r.u62()
const end = await r.u62()
const update = new SubscribeUpdate(priority)
update.order = order
update.expires = expires
update.start = start === 0n ? undefined : start - 1n
update.end = end === 0n ? undefined : end - 1n
return update
}
static async decode_maybe(r: Reader): Promise<SubscribeUpdate | undefined> {
if (await r.done()) return
return await SubscribeUpdate.decode(r)
}
}
export class Subscribe extends SubscribeUpdate {
id: bigint
path: string[]
static StreamID = 0x2
constructor(id: bigint, path: string[], priority: number) {
super(priority)
this.id = id
this.path = path
}
async encode(w: Writer) {
await w.u62(this.id)
await w.path(this.path)
await super.encode(w)
}
static async decode(r: Reader): Promise<Subscribe> {
const id = await r.u62()
const path = await r.path()
const update = await SubscribeUpdate.decode(r)
const subscribe = new Subscribe(id, path, update.priority)
subscribe.order = update.order
subscribe.expires = update.expires
subscribe.start = update.start
subscribe.end = update.end
return subscribe
}
}
export class Datagrams extends Subscribe {
static StreamID = 0x3
}
export class Info {
priority: number
order = Order.Descending
expires = 0
latest?: number
constructor(priority: number) {
this.priority = priority
}
async encode(w: Writer) {
await w.u53(this.priority)
await w.u53(this.order)
await w.u53(this.expires)
await w.u53(this.latest ? this.latest + 1 : 0)
}
static async decode(r: Reader): Promise<Info> {
const priority = await r.u53()
const order = await r.u53()
const latest = await r.u53()
const info = new Info(priority)
info.latest = latest === 0 ? undefined : latest - 1
info.order = order
return info
}
}
export class InfoRequest {
path: string[]
static StreamID = 0x5
constructor(path: string[]) {
this.path = path
}
async encode(w: Writer) {
await w.path(this.path)
}
static async decode(r: Reader): Promise<InfoRequest> {
const path = await r.path()
return new InfoRequest(path)
}
}
export class FetchUpdate {
priority: number
constructor(priority: number) {
this.priority = priority
}
async encode(w: Writer) {
await w.u53(this.priority)
}
static async decode(r: Reader): Promise<FetchUpdate> {
return new FetchUpdate(await r.u53())
}
static async decode_maybe(r: Reader): Promise<FetchUpdate | undefined> {
if (await r.done()) return
return await FetchUpdate.decode(r)
}
}
export class Fetch extends FetchUpdate {
path: string[]
static StreamID = 0x4
constructor(path: string[], priority: number) {
super(priority)
this.path = path
}
async encode(w: Writer) {
await w.path(this.path)
await super.encode(w)
}
static async decode(r: Reader): Promise<Fetch> {
const path = await r.path()
const update = await FetchUpdate.decode(r)
const fetch = new Fetch(path, update.priority)
return fetch
}
}
export class Group {
subscribe: bigint
sequence: number
static StreamID = 0x0
constructor(subscribe: bigint, sequence: number) {
this.subscribe = subscribe
this.sequence = sequence
}
async encode(w: Writer) {
await w.u62(this.subscribe)
await w.u53(this.sequence)
}
static async decode(r: Reader): Promise<Group> {
return new Group(await r.u62(), await r.u53())
}
}
export class GroupDrop {
sequence: number
count: number
error: number
constructor(sequence: number, count: number, error: number) {
this.sequence = sequence
this.count = count
this.error = error
}
async encode(w: Writer) {
await w.u53(this.sequence)
await w.u53(this.count)
await w.u53(this.error)
}
static async decode(r: Reader): Promise<GroupDrop> {
return new GroupDrop(await r.u53(), await r.u53(), await r.u53())
}
}
export class Frame {
payload: Uint8Array
constructor(payload: Uint8Array) {
this.payload = payload
}
async encode(w: Writer) {
await w.u53(this.payload.byteLength)
await w.write(this.payload)
}
static async decode(r: Reader): Promise<Frame> {
const size = await r.u53()
const payload = await r.read(size)
return new Frame(payload)
}
}
export type Bi = SessionClient | AnnounceInterest | Subscribe | Datagrams | Fetch | InfoRequest
export type Uni = Group

View File

@@ -0,0 +1,170 @@
import { Watch } from "../common/async"
import { Closed } from "./error"
import { Order } from "./message"
export class Track {
readonly path: string[]
readonly priority: number
order = Order.Any
// TODO use an array
latest = new Watch<GroupReader | undefined>(undefined)
readers = 0
closed?: Closed
constructor(path: string[], priority: number) {
this.path = path
this.priority = priority
}
appendGroup(): Group {
const next = this.latest.value()[0]?.id ?? 0
return this.createGroup(next)
}
createGroup(sequence: number): Group {
if (this.closed) throw this.closed
const group = new Group(sequence)
const [current, _] = this.latest.value()
// TODO use an array
if (!current || current.id < sequence) {
const reader = new GroupReader(group)
this.latest.update(reader)
}
return group
}
close(closed = new Closed()) {
if (this.closed) return
this.closed = closed
this.latest.close()
}
reader(): TrackReader {
// VERY important that readers are closed to decrement the count
this.readers += 1
return new TrackReader(this)
}
}
export class TrackReader {
latest?: number
#track: Track
constructor(track: Track) {
this.#track = track
}
async nextGroup(): Promise<GroupReader | undefined> {
let [current, next] = this.#track.latest.value()
for (;;) {
if (current && this.latest !== current.id) {
this.latest = current.id
return current
}
if (this.#track.closed) throw this.#track.closed
if (!next) return
;[current, next] = await next
}
}
get path() {
return this.#track.path
}
get order() {
return this.#track.order
}
get priority() {
return this.#track.priority
}
close() {
this.#track.readers -= 1
if (this.#track.readers <= 0) this.#track.close()
}
}
export class Group {
readonly id: number
chunks = new Watch<Uint8Array[]>([])
readers = 0
closed?: Closed
constructor(id: number) {
this.id = id
}
writeFrame(frame: Uint8Array) {
if (this.closed) throw this.closed
this.chunks.update((chunks) => [...chunks, frame])
}
writeFrames(...frames: Uint8Array[]) {
if (this.closed) throw this.closed
this.chunks.update((chunks) => [...chunks, ...frames])
this.close()
}
reader(): GroupReader {
this.readers += 1
return new GroupReader(this)
}
get length(): number {
return this.chunks.value()[0].length
}
close(closed = new Closed()) {
if (this.closed) return
this.closed = closed
this.chunks.close()
}
}
export class GroupReader {
#group: Group
#index = 0
constructor(group: Group) {
this.#group = group
}
async readFrame(): Promise<Uint8Array | undefined> {
let [chunks, next] = this.#group.chunks.value()
for (;;) {
if (this.#index < chunks.length) {
this.#index += 1
return chunks[this.#index - 1]
}
if (this.#group.closed) throw this.#group.closed
if (!next) return
;[chunks, next] = await next
}
}
get index(): number {
return this.#index
}
get id(): number {
return this.#group.id
}
close() {
this.#group.readers -= 1
if (this.#group.readers <= 0) this.#group.close()
}
}

View File

@@ -0,0 +1,173 @@
import { Watch } from "../common/async"
import { Closed } from "./error"
import * as Message from "./message"
import type { GroupReader, TrackReader } from "./model"
import { type Stream, Writer } from "./stream"
export class Publisher {
#quic: WebTransport
// Our announced broadcasts.
#announce = new Map<string[], TrackReader>()
// Their subscribed tracks.
#subscribe = new Map<bigint, Subscribed>()
constructor(quic: WebTransport) {
this.#quic = quic
}
// Publish a track
publish(track: TrackReader) {
if (this.#announce.has(track.path)) {
throw new Error(`already announced: ${track.path.toString()}`)
}
this.#announce.set(track.path, track)
// TODO: clean up announcements
// track.closed().then(() => this.#announce.delete(track.path))
}
#get(path: string[]): TrackReader | undefined {
return this.#announce.get(path)
}
async runAnnounce(msg: Message.AnnounceInterest, stream: Stream) {
for (const announce of this.#announce.values()) {
if (announce.path.length < msg.prefix.length) continue
const prefix = announce.path.slice(0, msg.prefix.length)
if (prefix !== msg.prefix) continue
const suffix = announce.path.slice(msg.prefix.length)
const active = new Message.Announce(suffix, "active")
await active.encode(stream.writer)
}
// TODO support updates.
// Until then, just keep the stream open.
await stream.reader.closed()
}
async runSubscribe(msg: Message.Subscribe, stream: Stream) {
if (this.#subscribe.has(msg.id)) {
throw new Error(`duplicate subscribe for id: ${msg.id}`)
}
const track = this.#get(msg.path)
if (!track) {
await stream.writer.reset(404)
return
}
const subscribe = new Subscribed(msg, track, this.#quic)
// TODO close the stream when done
subscribe.run().catch((err) => console.warn("failed to run subscribe: ", err))
try {
const info = new Message.Info(track.priority)
info.order = track.order
info.latest = track.latest
await info.encode(stream.writer)
for (;;) {
// TODO try_decode
const update = await Message.SubscribeUpdate.decode_maybe(stream.reader)
if (!update) {
subscribe.close()
break
}
// TODO use the update
}
} catch (err) {
subscribe.close(Closed.from(err))
}
}
async runDatagrams(msg: Message.Datagrams, stream: Stream) {
await stream.writer.reset(501)
throw new Error("datagrams not implemented")
}
async runFetch(msg: Message.Fetch, stream: Stream) {
await stream.writer.reset(501)
throw new Error("fetch not implemented")
}
async runInfo(msg: Message.InfoRequest, stream: Stream) {
const track = this.#get(msg.path)
if (!track) {
await stream.writer.reset(404)
return
}
const info = new Message.Info(track.priority)
info.order = track.order
info.latest = track.latest
await info.encode(stream.writer)
throw new Error("info not implemented")
}
}
class Subscribed {
#id: bigint
#track: TrackReader
#quic: WebTransport
#closed = new Watch<Closed | undefined>(undefined)
constructor(msg: Message.Subscribe, track: TrackReader, quic: WebTransport) {
this.#id = msg.id
this.#track = track
this.#quic = quic
}
async run() {
const closed = this.closed()
for (;;) {
const [group, done] = await Promise.all([this.#track.nextGroup(), closed])
if (done) return
if (!group) break
this.#runGroup(group).catch((err) => console.warn("failed to run group: ", err))
}
// TODO wait until all groups are done
this.close()
}
async #runGroup(group: GroupReader) {
const msg = new Message.Group(this.#id, group.id)
const stream = await Writer.open(this.#quic, msg)
for (;;) {
const frame = await group.readFrame()
if (!frame) break
await stream.u53(frame.byteLength)
await stream.write(frame)
}
}
close(err = new Closed()) {
this.#closed.update(err)
this.#track.close()
}
async closed(): Promise<Closed> {
let [closed, next] = this.#closed.value()
for (;;) {
if (closed !== undefined) return closed
if (!next) return new Closed()
;[closed, next] = await next
}
}
}

View File

@@ -1,10 +1,93 @@
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
import * as Message from "./message"
const MAX_U6 = 2 ** 6 - 1
const MAX_U14 = 2 ** 14 - 1
const MAX_U30 = 2 ** 30 - 1
const MAX_U31 = 2 ** 31 - 1
const MAX_U53 = Number.MAX_SAFE_INTEGER
const MAX_U62: bigint = 2n ** 62n - 1n
export class Stream {
reader: Reader
writer: Writer
constructor(props: {
writable: WritableStream<Uint8Array>
readable: ReadableStream<Uint8Array>
}) {
this.writer = new Writer(props.writable)
this.reader = new Reader(props.readable)
}
static async accept(quic: WebTransport): Promise<[Message.Bi, Stream] | undefined> {
const reader = quic.incomingBidirectionalStreams.getReader()
const next = await reader.read()
reader.releaseLock()
if (next.done) return
const stream = new Stream(next.value)
let msg: Message.Bi
const typ = await stream.reader.u8()
if (typ === Message.SessionClient.StreamID) {
msg = await Message.SessionClient.decode(stream.reader)
} else if (typ === Message.AnnounceInterest.StreamID) {
msg = await Message.AnnounceInterest.decode(stream.reader)
} else if (typ === Message.Subscribe.StreamID) {
msg = await Message.Subscribe.decode(stream.reader)
} else if (typ === Message.Datagrams.StreamID) {
msg = await Message.Datagrams.decode(stream.reader)
} else if (typ === Message.Fetch.StreamID) {
msg = await Message.Fetch.decode(stream.reader)
} else if (typ === Message.InfoRequest.StreamID) {
msg = await Message.InfoRequest.decode(stream.reader)
} else {
throw new Error(`unknown stream type: ${typ}`)
}
console.debug("accepted stream", msg)
return [msg, stream]
}
static async open(quic: WebTransport, msg: Message.Bi): Promise<Stream> {
const stream = new Stream(await quic.createBidirectionalStream())
if (msg instanceof Message.SessionClient) {
await stream.writer.u8(Message.SessionClient.StreamID)
} else if (msg instanceof Message.AnnounceInterest) {
await stream.writer.u8(Message.AnnounceInterest.StreamID)
} else if (msg instanceof Message.Subscribe) {
await stream.writer.u8(Message.Subscribe.StreamID)
} else if (msg instanceof Message.Datagrams) {
await stream.writer.u8(Message.Datagrams.StreamID)
} else if (msg instanceof Message.Fetch) {
await stream.writer.u8(Message.Fetch.StreamID)
} else if (msg instanceof Message.InfoRequest) {
await stream.writer.u8(Message.InfoRequest.StreamID)
} else {
// Make sure we're not missing any types.
const _: never = msg
throw new Error("invalid message type")
}
await msg.encode(stream.writer)
console.debug("opened stream", msg)
return stream
}
async close(code?: number) {
if (code === undefined) {
await this.writer.close()
} else {
await this.writer.reset(code)
await this.reader.stop(code)
}
}
}
// 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 {
@@ -12,7 +95,7 @@ export class Reader {
#stream: ReadableStream<Uint8Array>
#reader: ReadableStreamDefaultReader<Uint8Array>
constructor(buffer: Uint8Array, stream: ReadableStream<Uint8Array>) {
constructor(stream: ReadableStream<Uint8Array>, buffer = new Uint8Array()) {
this.#buffer = buffer
this.#stream = stream
this.#reader = this.#stream.getReader()
@@ -27,7 +110,7 @@ export class Reader {
const buffer = new Uint8Array(result.value)
if (this.#buffer.byteLength == 0) {
if (this.#buffer.byteLength === 0) {
this.#buffer = buffer
} else {
const temp = new Uint8Array(this.#buffer.byteLength + buffer.byteLength)
@@ -57,14 +140,13 @@ export class Reader {
}
async read(size: number): Promise<Uint8Array> {
if (size == 0) return new 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)
}
@@ -79,6 +161,17 @@ export class Reader {
return new TextDecoder().decode(buffer)
}
async path(): Promise<string[]> {
const parts = await this.u53()
const path = []
for (let i = 0; i < parts; i++) {
path.push(await this.string())
}
return path
}
async u8(): Promise<number> {
await this.#fillTo(1)
return this.#slice(1)[0]
@@ -99,30 +192,29 @@ export class Reader {
await this.#fillTo(1)
const size = (this.#buffer[0] & 0xc0) >> 6
if (size == 0) {
if (size === 0) {
const first = this.#slice(1)[0]
return BigInt(first) & 0x3fn
} else if (size == 1) {
}
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) {
}
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")
}
await this.#fillTo(8)
const slice = this.#slice(8)
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength)
return view.getBigUint64(0) & 0x3fffffffffffffffn
}
async done(): Promise<boolean> {
@@ -130,15 +222,38 @@ export class Reader {
return !(await this.#fill())
}
async close() {
async stop(code: number) {
this.#reader.releaseLock()
await this.#stream.cancel()
await this.#stream.cancel(code)
}
async closed() {
return this.#reader.closed
}
release(): [Uint8Array, ReadableStream<Uint8Array>] {
this.#reader.releaseLock()
return [this.#buffer, this.#stream]
}
static async accept(quic: WebTransport): Promise<[Message.Group, Reader] | undefined> {
const reader = quic.incomingUnidirectionalStreams.getReader()
const next = await reader.read()
reader.releaseLock()
if (next.done) return
const stream = new Reader(next.value)
let msg: Message.Uni
const typ = await stream.u8()
if (typ === Message.Group.StreamID) {
msg = await Message.Group.decode(stream)
} else {
throw new Error(`unknown stream type: ${typ}`)
}
return [msg, stream]
}
}
// Writer wraps a stream and writes chunks of data
@@ -170,7 +285,8 @@ export class Writer {
async u53(v: number) {
if (v < 0) {
throw new Error(`underflow, value is negative: ${v}`)
} else if (v > MAX_U53) {
}
if (v > MAX_U53) {
throw new Error(`overflow, value larger than 53-bits: ${v}`)
}
@@ -180,7 +296,8 @@ export class Writer {
async u62(v: bigint) {
if (v < 0) {
throw new Error(`underflow, value is negative: ${v}`)
} else if (v >= MAX_U62) {
}
if (v >= MAX_U62) {
throw new Error(`overflow, value larger than 62-bits: ${v}`)
}
@@ -197,72 +314,102 @@ export class Writer {
await this.write(data)
}
async path(path: string[]) {
await this.u53(path.length)
for (const part of path) {
await this.string(part)
}
}
async close() {
this.#writer.releaseLock()
await this.#stream.close()
}
async reset(code: number) {
this.#writer.releaseLock()
await this.#stream.abort(code)
}
release(): WritableStream<Uint8Array> {
this.#writer.releaseLock()
return this.#stream
}
static async open(quic: WebTransport, msg: Message.Uni): Promise<Writer> {
const stream = new Writer(await quic.createUnidirectionalStream())
if (msg instanceof Message.Group) {
await stream.u8(Message.Group.StreamID)
} else {
// Make sure we're not missing any types.
const _: never = msg
throw new Error("invalid message type")
}
return stream
}
}
function setUint8(dst: Uint8Array, v: number): Uint8Array {
export function setUint8(dst: Uint8Array, v: number): Uint8Array {
dst[0] = v
return dst.slice(0, 1)
}
function setUint16(dst: Uint8Array, v: number): Uint8Array {
export 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 {
export 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 {
export 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 {
export 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}`)
}
if (v <= MAX_U14) {
return setUint16(dst, v | 0x4000)
}
if (v <= MAX_U30) {
return setUint32(dst, v | 0x80000000)
}
if (v <= MAX_U53) {
return setUint64(dst, BigInt(v) | 0xc000000000000000n)
}
throw new Error(`overflow, value larger than 53-bits: ${v}`)
}
function setVint62(dst: Uint8Array, v: bigint): Uint8Array {
export 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}`)
}
if (v < MAX_U14) {
return setUint16(dst, Number(v) | 0x4000)
}
if (v <= MAX_U30) {
return setUint32(dst, Number(v) | 0x80000000)
}
if (v <= MAX_U62) {
return setUint64(dst, BigInt(v) | 0xc000000000000000n)
}
throw new Error(`overflow, value larger than 62-bits: ${v}`)
}
function setUint64(dst: Uint8Array, v: bigint): Uint8Array {
export function setUint64(dst: Uint8Array, v: bigint): Uint8Array {
const view = new DataView(dst.buffer, dst.byteOffset, 8)
view.setBigUint64(0, v)

View File

@@ -0,0 +1,169 @@
import { Queue, Watch } from "../common/async"
import { Closed } from "./error"
import { FrameReader } from "./frame"
import * as Message from "./message"
import type { Track, TrackReader } from "./model"
import { type Reader, Stream } from "./stream"
export class Subscriber {
#quic: WebTransport
// Our subscribed tracks.
#subscribe = new Map<bigint, Subscribe>()
#subscribeNext = 0n
constructor(quic: WebTransport) {
this.#quic = quic
}
async announced(prefix: string[]): Promise<Queue<Announced>> {
const announced = new Queue<Announced>()
const msg = new Message.AnnounceInterest(prefix)
const stream = await Stream.open(this.#quic, msg)
this.runAnnounced(stream, announced, prefix)
.then(() => announced.close())
.catch((err) => announced.abort(err))
return announced
}
async runAnnounced(stream: Stream, announced: Queue<Announced>, prefix: string[]) {
const toggle: Map<string[], Announced> = new Map()
try {
for (;;) {
const announce = await Message.Announce.decode_maybe(stream.reader)
if (!announce) {
break
}
const existing = toggle.get(announce.suffix)
if (existing) {
if (announce.status === "active") {
throw new Error("duplicate announce")
}
existing.close()
toggle.delete(announce.suffix)
} else {
if (announce.status === "closed") {
throw new Error("unknown announce")
}
const path = prefix.concat(announce.suffix)
const item = new Announced(path)
await announced.push(item)
toggle.set(announce.suffix, item)
}
}
} finally {
for (const item of toggle.values()) {
item.close()
}
}
}
// TODO: Deduplicate identical subscribes
async subscribe(track: Track): Promise<TrackReader> {
const id = this.#subscribeNext++
const msg = new Message.Subscribe(id, track.path, track.priority)
const stream = await Stream.open(this.#quic, msg)
const subscribe = new Subscribe(id, stream, track)
this.#subscribe.set(subscribe.id, subscribe)
try {
const ok = await Message.Info.decode(stream.reader)
/*
for (;;) {
const dropped = await Message.GroupDrop.decode(stream.reader)
console.debug("dropped", dropped)
}
*/
return subscribe.track.reader()
} catch (err) {
console.error(err)
this.#subscribe.delete(subscribe.id)
await subscribe.close(Closed.from(err))
throw err
}
}
async runGroup(group: Message.Group, stream: Reader) {
const subscribe = this.#subscribe.get(group.subscribe)
if (!subscribe) return
const writer = subscribe.track.createGroup(group.sequence)
const reader = new FrameReader(stream)
for (;;) {
const frame = await reader.read()
if (!frame) break
writer.writeFrame(frame)
}
writer.close()
}
}
export class Announced {
readonly path: string[]
#closed = new Watch<Closed | undefined>(undefined)
constructor(path: string[]) {
this.path = path
}
close(err = new Closed()) {
this.#closed.update(err)
}
async closed(): Promise<Closed> {
let [closed, next] = this.#closed.value()
for (;;) {
if (closed !== undefined) return closed
if (!next) return new Closed()
;[closed, next] = await next
}
}
}
export class Subscribe {
readonly id: bigint
readonly track: Track
readonly stream: Stream
// A queue of received streams for this subscription.
#closed = new Watch<Closed | undefined>(undefined)
constructor(id: bigint, stream: Stream, track: Track) {
this.id = id
this.track = track
this.stream = stream
}
async run() {
try {
await this.closed()
await this.close()
} catch (err) {
await this.close(Closed.from(err))
}
}
async close(closed?: Closed) {
this.track.close(closed)
await this.stream.close(closed?.code)
}
async closed() {
await this.stream.reader.closed()
}
}

View File

@@ -1,95 +0,0 @@
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

@@ -1,550 +0,0 @@
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

@@ -1,307 +0,0 @@
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

@@ -1,230 +0,0 @@
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

@@ -1,163 +0,0 @@
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

@@ -1,197 +0,0 @@
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

@@ -14,7 +14,7 @@
"isolatedModules": true,
"types": [], // Don't automatically import any @types modules.
"lib": ["es2022", "dom"],
"typeRoots": ["./types", "../node_modules/@types"]
"typeRoots": ["../node_modules/@types"]
},
"references": [
{
@@ -30,10 +30,10 @@
"path": "./contribute"
},
{
"path": "./transport"
"path": "./transfork"
},
{
"path": "./media"
"path": "./karp"
}
],
"paths": {

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +0,0 @@
{
"extends": "../tsconfig.json",
"include": ["."]
}