diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index db14b95a8..44c7f8595 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -29,4 +29,4 @@ jobs: cache-on-failure: true # Run checks with cached dependencies - - run: nix develop --command just check --workspace + - run: nix develop --command just check diff --git a/js/hang-demo/package.json b/js/hang-demo/package.json index d14e583b3..806f7f4af 100644 --- a/js/hang-demo/package.json +++ b/js/hang-demo/package.json @@ -14,11 +14,11 @@ "@kixelated/hang": "workspace:^" }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.12", + "@tailwindcss/postcss": "^4.1.13", "@tailwindcss/typography": "^0.5.16", - "@tailwindcss/vite": "^4.1.12", + "@tailwindcss/vite": "^4.1.13", "highlight.js": "^11.11.1", - "tailwindcss": "^4.1.12", + "tailwindcss": "^4.1.13", "typescript": "^5.9.2", "vite": "^6.3.5", "vite-plugin-html": "^3.2.2" diff --git a/js/hang-demo/vite.config.ts b/js/hang-demo/vite.config.ts index 08d02efe3..448c3bb14 100644 --- a/js/hang-demo/vite.config.ts +++ b/js/hang-demo/vite.config.ts @@ -20,4 +20,7 @@ export default defineConfig({ // TODO: properly support HMR hmr: false, }, + optimizeDeps: { + exclude: ["@libav.js/variant-opus"], + }, }); diff --git a/js/hang/package.json b/js/hang/package.json index 68d8e3235..496e4d4c1 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -28,7 +28,6 @@ ], "files": [ "./src", - "./dist", "README.md", "tsconfig.json" ], @@ -42,9 +41,11 @@ "@huggingface/transformers": "^3.7.2", "@kixelated/moq": "workspace:^", "@kixelated/signals": "workspace:^", + "@libav.js/variant-opus": "^6.8.8", "async-mutex": "^0.5.0", "comlink": "^4.4.2", - "zod": "^4.1.3" + "libavjs-webcodecs-polyfill": "^0.5.5", + "zod": "^4.1.5" }, "devDependencies": { "@types/audioworklet": "^0.0.77", diff --git a/js/hang/src/connection.ts b/js/hang/src/connection.ts index fb8675b84..cd25caeaa 100644 --- a/js/hang/src/connection.ts +++ b/js/hang/src/connection.ts @@ -17,9 +17,13 @@ export type ConnectionProps = { // The maximum delay in milliseconds. // default: 30000 maxDelay?: Time.Milli; + + // If true (default), attempt the WebSocket fallback. + // Currently this uses the same host/port as WebTransport, but a different protocol (TCP/WS) + websocket?: boolean; }; -export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "unsupported"; +export type ConnectionStatus = "connecting" | "connected" | "disconnected"; export class Connection { url: Signal; @@ -29,6 +33,7 @@ export class Connection { readonly reload: boolean; readonly delay: Time.Milli; readonly maxDelay: Time.Milli; + readonly websocket: boolean; signals = new Effect(); #delay: Time.Milli; @@ -41,15 +46,10 @@ export class Connection { this.reload = props?.reload ?? true; this.delay = props?.delay ?? (1000 as Time.Milli); this.maxDelay = props?.maxDelay ?? (30000 as Time.Milli); + this.websocket = props?.websocket ?? true; this.#delay = this.delay; - if (typeof WebTransport === "undefined") { - console.warn("WebTransport is not supported"); - this.status.set("unsupported"); - return; - } - // Create a reactive root so cleanup is easier. this.signals.effect(this.#connect.bind(this)); } @@ -65,7 +65,7 @@ export class Connection { effect.spawn(async (cancel) => { try { - const pending = Moq.connect(url); + const pending = Moq.connect(url, { websocket: this.websocket }); const connection = await Promise.race([cancel, pending]); if (!connection) { diff --git a/js/hang/src/frame.ts b/js/hang/src/frame.ts index b6fc6a339..95a4830b3 100644 --- a/js/hang/src/frame.ts +++ b/js/hang/src/frame.ts @@ -276,7 +276,7 @@ function getVint53(buf: Uint8Array): [number, Uint8Array] { if (size === 1) { v = buf[0] & 0x3f; } else if (size === 2) { - v = view.getInt16(0) & 0x3fff; + v = view.getUint16(0) & 0x3fff; } else if (size === 4) { v = view.getUint32(0) & 0x3fffffff; } else if (size === 8) { diff --git a/js/hang/src/publish/audio/index.ts b/js/hang/src/publish/audio/index.ts index 3f6b98ed9..d5d4df88a 100644 --- a/js/hang/src/publish/audio/index.ts +++ b/js/hang/src/publish/audio/index.ts @@ -4,6 +4,7 @@ import type * as Catalog from "../../catalog"; import { u8, u53 } from "../../catalog/integers"; import * as Frame from "../../frame"; import * as Time from "../../time"; +import * as libav from "../../util/libav"; import { Captions, type CaptionsProps } from "./captions"; import type * as Capture from "./capture"; @@ -189,61 +190,67 @@ export class Audio { let groupTimestamp = 0 as Time.Micro; - const encoder = new AudioEncoder({ - output: (frame) => { - if (frame.type !== "key") { - throw new Error("only key frames are supported"); - } - - if (frame.timestamp - groupTimestamp >= Time.Micro.fromMilli(this.maxLatency)) { - group.close(); - group = this.#track.appendGroup(); - groupTimestamp = frame.timestamp as Time.Micro; - } - - const buffer = Frame.encode(frame, frame.timestamp as Time.Micro); - group.writeFrame(buffer); - }, - error: (err) => { - group.abort(err); - }, - }); - effect.cleanup(() => encoder.close()); - - encoder.configure({ - codec: config.codec, - numberOfChannels: config.numberOfChannels, - sampleRate: config.sampleRate, - bitrate: config.bitrate, - }); + effect.spawn(async (cancel) => { + // We're using an async polyfill temporarily for Safari support. + const loaded = await Promise.race([libav.polyfill(), cancel]); + if (!loaded) return; // cancelled + + const encoder = new AudioEncoder({ + output: (frame) => { + if (frame.type !== "key") { + throw new Error("only key frames are supported"); + } + + if (frame.timestamp - groupTimestamp >= Time.Micro.fromMilli(this.maxLatency)) { + group.close(); + group = this.#track.appendGroup(); + groupTimestamp = frame.timestamp as Time.Micro; + } + + const buffer = Frame.encode(frame, frame.timestamp as Time.Micro); + group.writeFrame(buffer); + }, + error: (err) => { + group.abort(err); + }, + }); + effect.cleanup(() => encoder.close()); - effect.set(this.#config, config); - - worklet.port.onmessage = ({ data }: { data: Capture.AudioFrame }) => { - const channels = data.channels.slice(0, settings.channelCount); - const joinedLength = channels.reduce((a, b) => a + b.length, 0); - const joined = new Float32Array(joinedLength); - - channels.reduce((offset: number, channel: Float32Array): number => { - joined.set(channel, offset); - return offset + channel.length; - }, 0); - - const frame = new AudioData({ - format: "f32-planar", - sampleRate: worklet.context.sampleRate, - numberOfFrames: channels[0].length, - numberOfChannels: channels.length, - timestamp: data.timestamp, - data: joined, - transfer: [joined.buffer], + encoder.configure({ + codec: config.codec, + numberOfChannels: config.numberOfChannels, + sampleRate: config.sampleRate, + bitrate: config.bitrate, }); - encoder.encode(frame); - frame.close(); - }; - effect.cleanup(() => { - worklet.port.onmessage = null; + effect.set(this.#config, config); + + worklet.port.onmessage = ({ data }: { data: Capture.AudioFrame }) => { + const channels = data.channels.slice(0, settings.channelCount); + const joinedLength = channels.reduce((a, b) => a + b.length, 0); + const joined = new Float32Array(joinedLength); + + channels.reduce((offset: number, channel: Float32Array): number => { + joined.set(channel, offset); + return offset + channel.length; + }, 0); + + const frame = new AudioData({ + format: "f32-planar", + sampleRate: worklet.context.sampleRate, + numberOfFrames: channels[0].length, + numberOfChannels: channels.length, + timestamp: data.timestamp, + data: joined, + transfer: [joined.buffer], + }); + + encoder.encode(frame); + frame.close(); + }; + effect.cleanup(() => { + worklet.port.onmessage = null; + }); }); } diff --git a/js/hang/src/support/element.ts b/js/hang/src/support/element.ts index 48acd56c2..ef6ad527f 100644 --- a/js/hang/src/support/element.ts +++ b/js/hang/src/support/element.ts @@ -285,13 +285,13 @@ export default class HangSupport extends HTMLElement { container.appendChild(col3Div); }; - addRow("WebTransport", "", binary(support.webtransport)); + addRow("WebTransport", "", partial(support.webtransport)); if (mode !== "core") { if (mode !== "watch") { addRow("Capture", "Audio", binary(support.audio.capture)); addRow("", "Video", partial(support.video.capture)); - addRow("Encoding", "Opus", binary(support.audio.encoding?.opus)); + addRow("Encoding", "Opus", partial(support.audio.encoding?.opus)); addRow("", "AAC", binary(support.audio.encoding?.aac)); addRow("", "AV1", hardware(support.video.encoding?.av1)); addRow("", "H.265", hardware(support.video.encoding?.h265)); @@ -302,7 +302,7 @@ export default class HangSupport extends HTMLElement { if (mode !== "publish") { addRow("Rendering", "Audio", binary(support.audio.render)); addRow("", "Video", binary(support.video.render)); - addRow("Decoding", "Opus", binary(support.audio.decoding?.opus)); + addRow("Decoding", "Opus", partial(support.audio.decoding?.opus)); addRow("", "AAC", binary(support.audio.decoding?.aac)); addRow("", "AV1", hardware(support.video.decoding?.av1)); addRow("", "H.265", hardware(support.video.decoding?.h265)); diff --git a/js/hang/src/support/index.ts b/js/hang/src/support/index.ts index 953823c34..f01f21ba4 100644 --- a/js/hang/src/support/index.ts +++ b/js/hang/src/support/index.ts @@ -5,7 +5,7 @@ export type Partial = "full" | "partial" | "none"; export type Audio = { aac: boolean; - opus: boolean; + opus: Partial; }; export type Codec = { @@ -22,7 +22,7 @@ export type Video = { }; export type Full = { - webtransport: boolean; + webtransport: Partial; audio: { capture: boolean; encoding: Audio | undefined; @@ -115,21 +115,21 @@ async function videoEncoderSupported(codec: keyof typeof CODECS) { export async function isSupported(): Promise { return { - webtransport: typeof WebTransport !== "undefined", + webtransport: typeof WebTransport !== "undefined" ? "full" : "partial", audio: { capture: typeof AudioWorkletNode !== "undefined", encoding: typeof AudioEncoder !== "undefined" ? { aac: await audioEncoderSupported("aac"), - opus: await audioEncoderSupported("opus"), + opus: (await audioEncoderSupported("opus")) ? "full" : "partial", } : undefined, decoding: typeof AudioDecoder !== "undefined" ? { aac: await audioDecoderSupported("aac"), - opus: await audioDecoderSupported("opus"), + opus: (await audioDecoderSupported("opus")) ? "full" : "partial", } : undefined, render: typeof AudioContext !== "undefined" && typeof AudioBufferSourceNode !== "undefined", diff --git a/js/hang/src/util/libav.ts b/js/hang/src/util/libav.ts new file mode 100644 index 000000000..f0e144bf4 --- /dev/null +++ b/js/hang/src/util/libav.ts @@ -0,0 +1,26 @@ +let loading: Promise | undefined; + +// Returns true when the polyfill is loaded. +export async function polyfill(): Promise { + if (globalThis.AudioEncoder && globalThis.AudioDecoder) { + return true; + } + + if (!loading) { + console.warn("using Opus polyfill; performance may be degraded"); + + // Load the polyfill and the libav variant we're using. + // TODO build with AAC support. + // NOTE: we use require here to avoid tsc errors with libavjs-webcodecs-polyfill. + loading = Promise.all([require("@libav.js/variant-opus"), require("libavjs-webcodecs-polyfill")]).then( + async ([opus, libav]) => { + await libav.load({ + LibAV: opus, + polyfill: true, + }); + return true; + }, + ); + } + return await loading; +} diff --git a/js/hang/src/watch/audio/index.ts b/js/hang/src/watch/audio/index.ts index 4d9163811..83b5a86bf 100644 --- a/js/hang/src/watch/audio/index.ts +++ b/js/hang/src/watch/audio/index.ts @@ -4,6 +4,7 @@ import type * as Catalog from "../../catalog"; import * as Frame from "../../frame"; import type * as Time from "../../time"; import * as Hex from "../../util/hex"; +import * as libav from "../../util/libav"; import type * as Render from "./render"; export * from "./emitter"; @@ -153,27 +154,30 @@ export class Audio { const sub = broadcast.subscribe(info.track.name, info.track.priority); effect.cleanup(() => sub.close()); - const decoder = new AudioDecoder({ - output: (data) => this.#emit(data), - error: (error) => console.error(error), - }); - effect.cleanup(() => decoder.close()); + effect.spawn(async (cancel) => { + const loaded = await Promise.race([libav.polyfill(), cancel]); + if (!loaded) return; // cancelled - const config = info.config; - const description = config.description ? Hex.toBytes(config.description) : undefined; + const decoder = new AudioDecoder({ + output: (data) => this.#emit(data), + error: (error) => console.error(error), + }); + effect.cleanup(() => decoder.close()); - decoder.configure({ - ...config, - description, - }); + const config = info.config; + const description = config.description ? Hex.toBytes(config.description) : undefined; - // Create consumer with slightly less latency than the render worklet to avoid underflowing. - const consumer = new Frame.Consumer(sub, { - latency: Math.max(this.latency - JITTER_UNDERHEAD, 0) as Time.Milli, - }); - effect.cleanup(() => consumer.close()); + decoder.configure({ + ...config, + description, + }); + + // Create consumer with slightly less latency than the render worklet to avoid underflowing. + const consumer = new Frame.Consumer(sub, { + latency: Math.max(this.latency - JITTER_UNDERHEAD, 0) as Time.Milli, + }); + effect.cleanup(() => consumer.close()); - effect.spawn(async (cancel) => { for (;;) { const frame = await Promise.race([consumer.decode(), cancel]); if (!frame) break; diff --git a/js/hang/src/watch/audio/render-worklet.ts b/js/hang/src/watch/audio/render-worklet.ts index b9db0c955..6b3684fd3 100644 --- a/js/hang/src/watch/audio/render-worklet.ts +++ b/js/hang/src/watch/audio/render-worklet.ts @@ -12,7 +12,6 @@ class Render extends AudioWorkletProcessor { this.port.onmessage = (event: MessageEvent) => { const { type } = event.data; if (type === "init") { - console.debug(`init: ${event.data.latency}`); this.#buffer = new AudioRingBuffer(event.data); this.#underflow = 0; } else if (type === "data") { diff --git a/js/hang/src/watch/element.ts b/js/hang/src/watch/element.ts index b68040e1d..b33700033 100644 --- a/js/hang/src/watch/element.ts +++ b/js/hang/src/watch/element.ts @@ -128,7 +128,7 @@ export default class HangWatch extends HTMLElement { } } - // Make cooresponding properties for the element, more type-safe than using attributes. + // Make corresponding properties for the element, more type-safe than using attributes. get url(): URL | undefined { return this.connection.url.peek(); } diff --git a/js/justfile b/js/justfile index 61154e4e7..cef72d6ce 100644 --- a/js/justfile +++ b/js/justfile @@ -8,41 +8,29 @@ default: # Run the web server web url='http://localhost:4443/anon': - pnpm -r i + pnpm i VITE_RELAY_URL="{{url}}" pnpm -r run dev # Run the CI checks -check flags="": - pnpm -r install {{flags}} - - # Make sure Typescript compiles +check: + pnpm install --frozen-lockfile pnpm -r run check - - # Run the JS tests via node. pnpm -r test - - # Format/lint the JS packages pnpm exec biome check - - # TODO: Check for unused imports (fix the false positives) # pnpm exec knip --no-exit-code # Automatically fix some issues. -fix flags="": - # Fix the JS packages - pnpm -r install {{flags}} - - # Format and lint +fix: + pnpm install pnpm exec biome check --fix # Upgrade any tooling upgrade: - # Update the NPM dependencies pnpm self-update pnpm -r update pnpm -r outdated # Build the packages -build flags="": - pnpm -r install {{flags}} +build: + pnpm install --frozen-lockfile pnpm -r run build diff --git a/js/moq-token/package.json b/js/moq-token/package.json index be381bd7b..c3a5f13a6 100644 --- a/js/moq-token/package.json +++ b/js/moq-token/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@hexagon/base64": "^2.0.4", - "jose": "^6.0.13", - "zod": "^4.1.3" + "jose": "^6.1.0", + "zod": "^4.1.5" } } diff --git a/js/moq/package.json b/js/moq/package.json index 324fd8467..3b997f44a 100644 --- a/js/moq/package.json +++ b/js/moq/package.json @@ -16,11 +16,12 @@ "scripts": { "build": "rimraf dist && tsc -b && tsx ../scripts/package.ts", "check": "tsc --noEmit", - "test": "tsx --test", + "test": "tsx --test \"src/**/*.test.ts\"", "release": "tsx ../scripts/release.ts" }, "dependencies": { - "async-mutex": "^0.5.0" + "async-mutex": "^0.5.0", + "@kixelated/web-transport-ws": "^0.1.2" }, "peerDependencies": { "zod": "^4.0.0" diff --git a/js/moq/src/connection.ts b/js/moq/src/connection.ts index 4420f262e..100d52685 100644 --- a/js/moq/src/connection.ts +++ b/js/moq/src/connection.ts @@ -1,3 +1,4 @@ +import WebTransportWs from "@kixelated/web-transport-ws"; import type { AnnouncedConsumer } from "./announced"; import type { BroadcastConsumer } from "./broadcast"; import * as Ietf from "./ietf"; @@ -6,6 +7,7 @@ import type * as Path from "./path"; import { Stream } from "./stream"; import * as Hex from "./util/hex"; +// Both moq-lite and moq-ietf implement this. export interface Connection { readonly url: URL; @@ -16,44 +18,63 @@ export interface Connection { closed(): Promise; } +export interface ConnectionProps { + // WebTransport options. + webtransport?: WebTransportOptions; + + // If true (default), enable the WebSocket fallback. + // Currently this uses the same host/port as WebTransport, but a different protocol (TCP/WS) + websocket?: boolean; +} + +// Save if WebSocket won the last race, so we won't give QUIC a head start next time. +const websocketWon = new Map(); + /** * Establishes a connection to a MOQ server. * * @param url - The URL of the server to connect to * @returns A promise that resolves to a Connection instance */ -export async function connect(url: URL): Promise { - const options: WebTransportOptions = { - allowPooling: false, - congestionControl: "low-latency", - requireUnreliable: true, - }; +export async function connect(url: URL, props?: ConnectionProps): Promise { + // Create a cancel promise to kill whichever is still connecting. + let done: (() => void) | undefined; + const cancel = new Promise((resolve) => { + done = resolve; + }); - let adjustedUrl = url; + const webtransport = globalThis.WebTransport ? connectWebTransport(url, cancel, props?.webtransport) : undefined; - if (url.protocol === "http:") { - const fingerprintUrl = new URL(url); - fingerprintUrl.pathname = "/certificate.sha256"; - fingerprintUrl.search = ""; - console.warn(fingerprintUrl.toString(), "performing an insecure fingerprint fetch; use https:// in production"); + // Give QUIC a 200ms head start to connect before trying WebSocket, unless WebSocket has won in the past. + // NOTE that QUIC should be faster because it involves 1/2 fewer RTTs. + const headstart = !webtransport || websocketWon.get(url) ? 0 : 200; + const websocket = + props?.websocket !== false + ? new Promise((resolve) => setTimeout(resolve, headstart)).then(() => { + if (headstart) { + console.debug(url.toString(), "no WebTransport after 200ms, attempting WebSocket fallback"); + } + return connectWebSocket(url, cancel); + }) + : undefined; - // Fetch the fingerprint from the server. - const fingerprint = await fetch(fingerprintUrl); - const fingerprintText = await fingerprint.text(); + if (!websocket && !webtransport) { + throw new Error("no transport available; WebTransport not supported and WebSocket is disabled"); + } - options.serverCertificateHashes = [ - { - algorithm: "sha-256", - value: Hex.toBytes(fingerprintText), - }, - ]; + // Race them, using `.any` to ignore if one participant has a error. + const quic = await Promise.any( + webtransport ? (websocket ? [websocket, webtransport] : [webtransport]) : [websocket], + ); + if (done) done(); - adjustedUrl = new URL(url); - adjustedUrl.protocol = "https:"; - } + if (!quic) throw new Error("no transport available"); - const quic = new WebTransport(adjustedUrl, options); - await quic.ready; + // Save if WebSocket won the last race, so we won't give QUIC a head start next time. + if (quic instanceof WebTransportWs) { + console.warn(url.toString(), "using WebSocket fallback; the user experience may be degraded"); + websocketWon.set(url, true); + } // moq-rs currently requires the ROLE extension to be set. const extensions = new Lite.Extensions(); @@ -74,12 +95,79 @@ export async function connect(url: URL): Promise { const server = await Lite.SessionServer.decode(stream.reader); if (server.version === Lite.CURRENT_VERSION) { - console.debug("moq-lite session established"); - return new Lite.Connection(adjustedUrl, quic, stream); + console.debug(url.toString(), "moq-lite session established"); + return new Lite.Connection(url, quic, stream); } else if (server.version === Ietf.CURRENT_VERSION) { - console.debug("moq-ietf session established"); - return new Ietf.Connection(adjustedUrl, quic, stream); + console.debug(url.toString(), "moq-ietf session established"); + return new Ietf.Connection(url, quic, stream); } else { throw new Error(`unsupported server version: ${server.version.toString()}`); } } + +async function connectWebTransport( + url: URL, + cancel: Promise, + options?: WebTransportOptions, +): Promise { + let finalUrl = url; + + const finalOptions: WebTransportOptions = { + allowPooling: false, + congestionControl: "low-latency", + ...options, + }; + + // Only perform certificate fetch and URL rewrite when polyfill is not needed + // This is needed because WebTransport is a butt to work with in local development. + if (url.protocol === "http:") { + const fingerprintUrl = new URL(url); + fingerprintUrl.pathname = "/certificate.sha256"; + fingerprintUrl.search = ""; + console.warn(fingerprintUrl.toString(), "performing an insecure fingerprint fetch; use https:// in production"); + + // Fetch the fingerprint from the server. + const fingerprint = await Promise.race([fetch(fingerprintUrl), cancel]); + if (!fingerprint) return undefined; + + const fingerprintText = await Promise.race([fingerprint.text(), cancel]); + if (fingerprintText === undefined) return undefined; + + finalOptions.serverCertificateHashes = (finalOptions.serverCertificateHashes || []).concat([ + { + algorithm: "sha-256", + value: Hex.toBytes(fingerprintText), + }, + ]); + + finalUrl = new URL(url); + finalUrl.protocol = "https:"; + } + + const quic = new WebTransport(finalUrl, finalOptions); + + // Wait for the WebTransport to connect, or for the cancel promise to resolve. + // Close the connection if we lost the race. + const loaded = await Promise.race([quic.ready.then(() => true), cancel]); + if (!loaded) { + quic.close(); + return undefined; + } + + return quic; +} + +// TODO accept arguments to control the port/path used. +async function connectWebSocket(url: URL, cancel: Promise): Promise { + const quic = new WebTransportWs(url); + + // Wait for the WebSocket to connect, or for the cancel promise to resolve. + // Close the connection if we lost the race. + const loaded = await Promise.race([quic.ready.then(() => true), cancel]); + if (!loaded) { + quic.close(); + return undefined; + } + + return quic; +} diff --git a/js/moq/src/lite/connection.ts b/js/moq/src/lite/connection.ts index da0d36c22..9a99e0f77 100644 --- a/js/moq/src/lite/connection.ts +++ b/js/moq/src/lite/connection.ts @@ -112,14 +112,16 @@ export class Connection implements ConnectionInterface { } async #runSession() { - // Receive messages until the connection is closed. - for (;;) { - const msg = await SessionInfo.decodeMaybe(this.#session.reader); - if (!msg) break; - // TODO use the session info + try { + // Receive messages until the connection is closed. + for (;;) { + const msg = await SessionInfo.decodeMaybe(this.#session.reader); + if (!msg) break; + // TODO use the session info + } + } finally { + console.warn("session stream closed"); } - - console.warn("session stream closed"); } async #runBidis() { diff --git a/js/moq/src/stream.ts b/js/moq/src/stream.ts index 149911519..e5fdd14d8 100644 --- a/js/moq/src/stream.ts +++ b/js/moq/src/stream.ts @@ -66,11 +66,19 @@ export class Reader { // Adds more data to the buffer, returning true if more data was added. async #fill(): Promise { - const result = await this.#reader?.read(); - if (!result || result.done) { + if (!this.#reader) { return false; } + const result = await this.#reader.read(); + if (result.done) { + return false; + } + + if (result.value.byteLength === 0) { + throw new Error("unexpected empty chunk"); + } + const buffer = new Uint8Array(result.value); if (this.#buffer.byteLength === 0) { @@ -180,7 +188,7 @@ export class Reader { const slice = this.#slice(2); const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength); - return BigInt(view.getInt16(0)) & 0x3fffn; + return BigInt(view.getUint16(0)) & 0x3fffn; } if (size === 2) { await this.#fillTo(4); diff --git a/js/package.json b/js/package.json index edf73b9dc..9b44755ea 100644 --- a/js/package.json +++ b/js/package.json @@ -5,13 +5,13 @@ "type": "module", "devDependencies": { "@biomejs/biome": "^2.2.2", - "@types/node": "^24.3.0", + "@types/node": "^24.3.1", "concurrently": "^9.2.1", "cpy-cli": "^5.0.0", - "knip": "^5.63.0", + "knip": "^5.63.1", "rimraf": "^6.0.1", "tsx": "^4.20.5", "typescript": "^5.9.2" }, - "packageManager": "pnpm@10.12.1" + "packageManager": "pnpm@10.15.1" } diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 5e957ead2..d47874a6d 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: specifier: ^2.2.2 version: 2.2.2 '@types/node': - specifier: ^24.3.0 - version: 24.3.0 + specifier: ^24.3.1 + version: 24.3.1 concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -25,8 +25,8 @@ importers: specifier: ^5.0.0 version: 5.0.0 knip: - specifier: ^5.63.0 - version: 5.63.0(@types/node@24.3.0)(typescript@5.9.2) + specifier: ^5.63.1 + version: 5.63.1(@types/node@24.3.1)(typescript@5.9.2) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -48,14 +48,20 @@ importers: '@kixelated/signals': specifier: workspace:^ version: link:../signals + '@libav.js/variant-opus': + specifier: ^6.8.8 + version: 6.8.8 async-mutex: specifier: ^0.5.0 version: 0.5.0 comlink: specifier: ^4.4.2 version: 4.4.2 + libavjs-webcodecs-polyfill: + specifier: ^0.5.5 + version: 0.5.5 zod: - specifier: ^4.1.3 + specifier: ^4.1.5 version: 4.1.5 devDependencies: '@types/audioworklet': @@ -69,7 +75,7 @@ importers: version: 3.3.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5) + version: 3.2.4(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5) hang-demo: dependencies: @@ -78,32 +84,35 @@ importers: version: link:../hang devDependencies: '@tailwindcss/postcss': - specifier: ^4.1.12 - version: 4.1.12 + specifier: ^4.1.13 + version: 4.1.13 '@tailwindcss/typography': specifier: ^0.5.16 - version: 0.5.16(tailwindcss@4.1.12) + version: 0.5.16(tailwindcss@4.1.13) '@tailwindcss/vite': - specifier: ^4.1.12 - version: 4.1.12(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)) + specifier: ^4.1.13 + version: 4.1.13(vite@6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5)) highlight.js: specifier: ^11.11.1 version: 11.11.1 tailwindcss: - specifier: ^4.1.12 - version: 4.1.12 + specifier: ^4.1.13 + version: 4.1.13 typescript: specifier: ^5.9.2 version: 5.9.2 vite: specifier: ^6.3.5 - version: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5) + version: 6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5) vite-plugin-html: specifier: ^3.2.2 - version: 3.2.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)) + version: 3.2.2(vite@6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5)) moq: dependencies: + '@kixelated/web-transport-ws': + specifier: ^0.1.2 + version: 0.1.2 async-mutex: specifier: ^0.5.0 version: 0.5.0 @@ -119,10 +128,10 @@ importers: version: '@types/web@0.0.241' vite: specifier: ^6.3.5 - version: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5) + version: 6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5) vite-plugin-html: specifier: ^3.2.2 - version: 3.2.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)) + version: 3.2.2(vite@6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5)) moq-clock: dependencies: @@ -140,10 +149,10 @@ importers: specifier: ^2.0.4 version: 2.0.4 jose: - specifier: ^6.0.13 + specifier: ^6.1.0 version: 6.1.0 zod: - specifier: ^4.1.3 + specifier: ^4.1.5 version: 4.1.5 signals: @@ -552,6 +561,15 @@ packages: '@jridgewell/trace-mapping@0.3.30': resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + '@kixelated/web-transport-ws@0.1.2': + resolution: {integrity: sha512-rRUQuKxMgZ9LCqsdrrIJuYR59RRcUZWWBDAWHHPKHT506xQ4vkscoVCm55SM5hoEqNCRke8o7+75oVTTiFxM+Q==} + + '@libav.js/types@6.8.8': + resolution: {integrity: sha512-Lbik/0Q3x2R8cI7mOtRgt+nUWLqGXh7UinMndmpdXSDY4YEjYyVUDsq6fxkuriL78+LCYx8frZIN1r+oDsvYCQ==} + + '@libav.js/variant-opus@6.8.8': + resolution: {integrity: sha512-RMvPGOfqAkqfNbCsrA6u/NiErrLK5pwSzc3bVVDbmsb/Ig0asn5vQ5CxFjIJg/aSUfep1tvEf0/xfhLzy55PBw==} + '@napi-rs/wasm-runtime@1.0.3': resolution: {integrity: sha512-rZxtMsLwjdXkMUGC3WwsPwLNVqVqnTJT6MNIB6e+5fhMcSCPP0AOsNWuMQ5mdCq6HNjs/ZeWAEchpqeprqBD2Q==} @@ -801,65 +819,65 @@ packages: cpu: [x64] os: [win32] - '@tailwindcss/node@4.1.12': - resolution: {integrity: sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==} + '@tailwindcss/node@4.1.13': + resolution: {integrity: sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==} - '@tailwindcss/oxide-android-arm64@4.1.12': - resolution: {integrity: sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==} + '@tailwindcss/oxide-android-arm64@4.1.13': + resolution: {integrity: sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.1.12': - resolution: {integrity: sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==} + '@tailwindcss/oxide-darwin-arm64@4.1.13': + resolution: {integrity: sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.1.12': - resolution: {integrity: sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==} + '@tailwindcss/oxide-darwin-x64@4.1.13': + resolution: {integrity: sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.1.12': - resolution: {integrity: sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==} + '@tailwindcss/oxide-freebsd-x64@4.1.13': + resolution: {integrity: sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==} engines: {node: '>= 10'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.12': - resolution: {integrity: sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13': + resolution: {integrity: sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.1.12': - resolution: {integrity: sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==} + '@tailwindcss/oxide-linux-arm64-gnu@4.1.13': + resolution: {integrity: sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-arm64-musl@4.1.12': - resolution: {integrity: sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==} + '@tailwindcss/oxide-linux-arm64-musl@4.1.13': + resolution: {integrity: sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-x64-gnu@4.1.12': - resolution: {integrity: sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==} + '@tailwindcss/oxide-linux-x64-gnu@4.1.13': + resolution: {integrity: sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-linux-x64-musl@4.1.12': - resolution: {integrity: sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==} + '@tailwindcss/oxide-linux-x64-musl@4.1.13': + resolution: {integrity: sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-wasm32-wasi@4.1.12': - resolution: {integrity: sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==} + '@tailwindcss/oxide-wasm32-wasi@4.1.13': + resolution: {integrity: sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -870,32 +888,32 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.1.12': - resolution: {integrity: sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==} + '@tailwindcss/oxide-win32-arm64-msvc@4.1.13': + resolution: {integrity: sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.1.12': - resolution: {integrity: sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==} + '@tailwindcss/oxide-win32-x64-msvc@4.1.13': + resolution: {integrity: sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.1.12': - resolution: {integrity: sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==} + '@tailwindcss/oxide@4.1.13': + resolution: {integrity: sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==} engines: {node: '>= 10'} - '@tailwindcss/postcss@4.1.12': - resolution: {integrity: sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==} + '@tailwindcss/postcss@4.1.13': + resolution: {integrity: sha512-HLgx6YSFKJT7rJqh9oJs/TkBFhxuMOfUKSBEPYwV+t78POOBsdQ7crhZLzwcH3T0UyUuOzU/GK5pk5eKr3wCiQ==} '@tailwindcss/typography@0.5.16': resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==} peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tailwindcss/vite@4.1.12': - resolution: {integrity: sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==} + '@tailwindcss/vite@4.1.13': + resolution: {integrity: sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 @@ -917,8 +935,8 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/node@24.3.0': - resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==} + '@types/node@24.3.1': + resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==} '@types/react@19.1.12': resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==} @@ -926,6 +944,9 @@ packages: '@types/web@0.0.241': resolution: {integrity: sha512-aHz73musjS4z8Sm0wLaEj4lOaRxhKZRD4XerKL4rxNhhqmvthKkYzS0kHLEVsjOFRN2zo3gQAlyf0EKL1QYYQg==} + '@ungap/global-this@0.4.4': + resolution: {integrity: sha512-mHkm6FvepJECMNthFuIgpAEFmPOk71UyXuIxYfjytvFTnSDBIz7jmViO+LfHI/AjrazWije0PnSP3+/NlwzqtA==} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -1415,14 +1436,17 @@ packages: resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==} engines: {node: '>=12.20'} - knip@5.63.0: - resolution: {integrity: sha512-xIFIi/uvLW0S/AQqwggN6UVRKBOQ1Ya7jBfQzllswZplr2si5C616/5wCcWc/eoi1PLJgPgJQLxqYq1aiYpqwg==} + knip@5.63.1: + resolution: {integrity: sha512-wSznedUAzcU4o9e0O2WPqDnP7Jttu8cesq/R23eregRY8QYQ9NLJ3aGt9fadJfRzPBoU4tRyutwVQu6chhGDlA==} engines: {node: '>=18.18.0'} hasBin: true peerDependencies: '@types/node': '>=18' typescript: '>=5.0.4' + libavjs-webcodecs-polyfill@0.5.5: + resolution: {integrity: sha512-1RhFwKgYg/dsn0Ej4kf0z35t6G3/JZnStKqPHArOqALlvwG654NZes0AFXaJGzRLBknwLXiKw9qlemct97YE7w==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -1505,8 +1529,8 @@ packages: lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} - lru-cache@11.1.0: - resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + lru-cache@11.2.1: + resolution: {integrity: sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==} engines: {node: 20 || >=22} magic-string@0.30.18: @@ -1728,8 +1752,8 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} - seroval-plugins@1.3.2: - resolution: {integrity: sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==} + seroval-plugins@1.3.3: + resolution: {integrity: sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 @@ -1826,8 +1850,8 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} - tailwindcss@4.1.12: - resolution: {integrity: sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==} + tailwindcss@4.1.13: + resolution: {integrity: sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==} tapable@2.2.3: resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} @@ -1837,8 +1861,8 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} - terser@5.43.1: - resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==} + terser@5.44.0: + resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} engines: {node: '>=10'} hasBin: true @@ -1949,6 +1973,46 @@ packages: yaml: optional: true + vite@7.1.4: + resolution: {integrity: sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@3.2.4: resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2300,6 +2364,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kixelated/web-transport-ws@0.1.2': {} + + '@libav.js/types@6.8.8': {} + + '@libav.js/variant-opus@6.8.8': {} + '@napi-rs/wasm-runtime@1.0.3': dependencies: '@emnapi/core': 1.5.0 @@ -2469,7 +2539,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.50.0': optional: true - '@tailwindcss/node@4.1.12': + '@tailwindcss/node@4.1.13': dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.18.3 @@ -2477,84 +2547,84 @@ snapshots: lightningcss: 1.30.1 magic-string: 0.30.18 source-map-js: 1.2.1 - tailwindcss: 4.1.12 + tailwindcss: 4.1.13 - '@tailwindcss/oxide-android-arm64@4.1.12': + '@tailwindcss/oxide-android-arm64@4.1.13': optional: true - '@tailwindcss/oxide-darwin-arm64@4.1.12': + '@tailwindcss/oxide-darwin-arm64@4.1.13': optional: true - '@tailwindcss/oxide-darwin-x64@4.1.12': + '@tailwindcss/oxide-darwin-x64@4.1.13': optional: true - '@tailwindcss/oxide-freebsd-x64@4.1.12': + '@tailwindcss/oxide-freebsd-x64@4.1.13': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.12': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.1.12': + '@tailwindcss/oxide-linux-arm64-gnu@4.1.13': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.1.12': + '@tailwindcss/oxide-linux-arm64-musl@4.1.13': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.1.12': + '@tailwindcss/oxide-linux-x64-gnu@4.1.13': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.1.12': + '@tailwindcss/oxide-linux-x64-musl@4.1.13': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.1.12': + '@tailwindcss/oxide-wasm32-wasi@4.1.13': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.1.12': + '@tailwindcss/oxide-win32-arm64-msvc@4.1.13': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.1.12': + '@tailwindcss/oxide-win32-x64-msvc@4.1.13': optional: true - '@tailwindcss/oxide@4.1.12': + '@tailwindcss/oxide@4.1.13': dependencies: detect-libc: 2.0.4 tar: 7.4.3 optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.12 - '@tailwindcss/oxide-darwin-arm64': 4.1.12 - '@tailwindcss/oxide-darwin-x64': 4.1.12 - '@tailwindcss/oxide-freebsd-x64': 4.1.12 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.12 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.12 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.12 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.12 - '@tailwindcss/oxide-linux-x64-musl': 4.1.12 - '@tailwindcss/oxide-wasm32-wasi': 4.1.12 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.12 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.12 - - '@tailwindcss/postcss@4.1.12': + '@tailwindcss/oxide-android-arm64': 4.1.13 + '@tailwindcss/oxide-darwin-arm64': 4.1.13 + '@tailwindcss/oxide-darwin-x64': 4.1.13 + '@tailwindcss/oxide-freebsd-x64': 4.1.13 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.13 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.13 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.13 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.13 + '@tailwindcss/oxide-linux-x64-musl': 4.1.13 + '@tailwindcss/oxide-wasm32-wasi': 4.1.13 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.13 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.13 + + '@tailwindcss/postcss@4.1.13': dependencies: '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.1.12 - '@tailwindcss/oxide': 4.1.12 + '@tailwindcss/node': 4.1.13 + '@tailwindcss/oxide': 4.1.13 postcss: 8.5.6 - tailwindcss: 4.1.12 + tailwindcss: 4.1.13 - '@tailwindcss/typography@0.5.16(tailwindcss@4.1.12)': + '@tailwindcss/typography@0.5.16(tailwindcss@4.1.13)': dependencies: lodash.castarray: 4.4.0 lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 postcss-selector-parser: 6.0.10 - tailwindcss: 4.1.12 + tailwindcss: 4.1.13 - '@tailwindcss/vite@4.1.12(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5))': + '@tailwindcss/vite@4.1.13(vite@6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5))': dependencies: - '@tailwindcss/node': 4.1.12 - '@tailwindcss/oxide': 4.1.12 - tailwindcss: 4.1.12 - vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5) + '@tailwindcss/node': 4.1.13 + '@tailwindcss/oxide': 4.1.13 + tailwindcss: 4.1.13 + vite: 6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5) '@tybys/wasm-util@0.10.0': dependencies: @@ -2573,7 +2643,7 @@ snapshots: '@types/estree@1.0.8': {} - '@types/node@24.3.0': + '@types/node@24.3.1': dependencies: undici-types: 7.10.0 @@ -2583,6 +2653,8 @@ snapshots: '@types/web@0.0.241': {} + '@ungap/global-this@0.4.4': {} + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.2 @@ -2591,13 +2663,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5))': + '@vitest/mocker@3.2.4(vite@7.1.4(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.18 optionalDependencies: - vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5) + vite: 7.1.4(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5) '@vitest/pretty-format@3.2.4': dependencies: @@ -3023,7 +3095,7 @@ snapshots: he: 1.2.0 param-case: 3.0.4 relateurl: 0.2.7 - terser: 5.43.1 + terser: 5.44.0 ignore@5.3.2: {} @@ -3073,10 +3145,10 @@ snapshots: junk@4.0.1: {} - knip@5.63.0(@types/node@24.3.0)(typescript@5.9.2): + knip@5.63.1(@types/node@24.3.1)(typescript@5.9.2): dependencies: '@nodelib/fs.walk': 1.2.8 - '@types/node': 24.3.0 + '@types/node': 24.3.1 fast-glob: 3.3.3 formatly: 0.3.0 jiti: 2.5.1 @@ -3091,6 +3163,11 @@ snapshots: zod: 3.25.76 zod-validation-error: 3.5.3(zod@3.25.76) + libavjs-webcodecs-polyfill@0.5.5: + dependencies: + '@libav.js/types': 6.8.8 + '@ungap/global-this': 0.4.4 + lightningcss-darwin-arm64@1.30.1: optional: true @@ -3150,7 +3227,7 @@ snapshots: dependencies: tslib: 2.8.1 - lru-cache@11.1.0: {} + lru-cache@11.2.1: {} magic-string@0.30.18: dependencies: @@ -3286,7 +3363,7 @@ snapshots: path-scurry@2.0.0: dependencies: - lru-cache: 11.1.0 + lru-cache: 11.2.1 minipass: 7.1.2 path-type@4.0.0: {} @@ -3328,7 +3405,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 24.3.0 + '@types/node': 24.3.1 long: 5.3.2 queue-microtask@1.2.3: {} @@ -3400,7 +3477,7 @@ snapshots: dependencies: type-fest: 0.13.1 - seroval-plugins@1.3.2(seroval@1.3.2): + seroval-plugins@1.3.3(seroval@1.3.2): dependencies: seroval: 1.3.2 @@ -3459,7 +3536,7 @@ snapshots: dependencies: csstype: 3.1.3 seroval: 1.3.2 - seroval-plugins: 1.3.2(seroval@1.3.2) + seroval-plugins: 1.3.3(seroval@1.3.2) source-map-js@1.2.1: {} @@ -3510,7 +3587,7 @@ snapshots: dependencies: has-flag: 4.0.0 - tailwindcss@4.1.12: {} + tailwindcss@4.1.13: {} tapable@2.2.3: {} @@ -3523,7 +3600,7 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 - terser@5.43.1: + terser@5.44.0: dependencies: '@jridgewell/source-map': 0.3.11 acorn: 8.15.0 @@ -3570,13 +3647,13 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5): + vite-node@3.2.4(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5) + vite: 7.1.4(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5) transitivePeerDependencies: - '@types/node' - jiti @@ -3591,7 +3668,7 @@ snapshots: - tsx - yaml - vite-plugin-html@3.2.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)): + vite-plugin-html@3.2.2(vite@6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5)): dependencies: '@rollup/pluginutils': 4.2.1 colorette: 2.0.20 @@ -3605,9 +3682,25 @@ snapshots: html-minifier-terser: 6.1.0 node-html-parser: 5.4.2 pathe: 0.2.0 - vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5) + vite: 6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5) + + vite@6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5): + dependencies: + esbuild: 0.25.9 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.50.0 + tinyglobby: 0.2.14 + optionalDependencies: + '@types/node': 24.3.1 + fsevents: 2.3.3 + jiti: 2.5.1 + lightningcss: 1.30.1 + terser: 5.44.0 + tsx: 4.20.5 - vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5): + vite@7.1.4(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -3616,18 +3709,18 @@ snapshots: rollup: 4.50.0 tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 24.3.1 fsevents: 2.3.3 jiti: 2.5.1 lightningcss: 1.30.1 - terser: 5.43.1 + terser: 5.44.0 tsx: 4.20.5 - vitest@3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5): + vitest@3.2.4(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)) + '@vitest/mocker': 3.2.4(vite@7.1.4(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -3645,11 +3738,11 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5) - vite-node: 3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5) + vite: 7.1.4(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5) + vite-node: 3.2.4(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.5) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 24.3.1 transitivePeerDependencies: - jiti - less diff --git a/justfile b/justfile index b701e6b51..7cca91575 100644 --- a/justfile +++ b/justfile @@ -92,14 +92,14 @@ clock action: just --justfile rs/justfile clock {{action}} # Run the CI checks -check flags="": - just --justfile rs/justfile check {{flags}} +check: + just --justfile rs/justfile check just --justfile js/justfile check #@if which nix > /dev/null; then nix fmt -- --fail-on-change; else echo "nix not found, skipping Nix formatting check"; fi # Automatically fix some issues. -fix flags="": - just --justfile rs/justfile fix {{flags}} +fix: + just --justfile rs/justfile fix just --justfile js/justfile fix #@if which nix > /dev/null; then nix fmt; else echo "nix not found, skipping Nix formatting"; fi diff --git a/rs/.cargo/config.toml b/rs/.cargo/config.toml deleted file mode 100644 index 84671750f..000000000 --- a/rs/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -rustflags = ["--cfg=web_sys_unstable_apis"] diff --git a/rs/Cargo.lock b/rs/Cargo.lock index a2122b3b9..098a550cb 100644 --- a/rs/Cargo.lock +++ b/rs/Cargo.lock @@ -161,6 +161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -180,8 +181,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite 0.26.2", "tower 0.5.2", "tower-layer", "tower-service", @@ -254,9 +257,18 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.9.3" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] [[package]] name = "bumpalo" @@ -264,6 +276,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" @@ -275,10 +293,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.34" +version = "1.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +checksum = "590f9024a68a8c40351881787f1934dc11afd69090f5edb6831464694d836ea3" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -339,9 +358,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.46" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" dependencies = [ "clap_builder", "clap_derive", @@ -349,9 +368,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.46" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" dependencies = [ "anstream", "anstyle", @@ -361,9 +380,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.45" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck", "proc-macro2", @@ -427,12 +446,31 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.20.11" @@ -468,11 +506,17 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" dependencies = [ "powerfmt", "serde", @@ -499,6 +543,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -565,12 +619,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastbloom" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18c1ddb9231d8554c2d6bdf4cfaabf0c59251658c68b6c95cd52dd0c513a912a" +dependencies = [ + "getrandom 0.3.3", + "libm", + "rand 0.9.2", + "siphasher", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e178e4fba8a2726903f6ba98a6d221e76f9c12c650d5dc0e6afdc50677b49650" + [[package]] name = "fnv" version = "1.0.7" @@ -681,6 +753,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -704,7 +786,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.3+wasi-0.2.4", "wasm-bindgen", ] @@ -925,7 +1007,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.0", + "socket2", "tokio", "tower-service", "tracing", @@ -1223,6 +1305,12 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1253,9 +1341,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "matchers" @@ -1347,7 +1441,7 @@ dependencies = [ "tokio", "tracing", "web-async", - "web-transport", + "web-transport-trait", ] [[package]] @@ -1373,7 +1467,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", - "web-transport", + "web-transport-quinn", "webpki", ] @@ -1385,6 +1479,7 @@ dependencies = [ "axum", "bytes", "clap", + "futures", "http-body", "hyper-serve", "moq-lite", @@ -1399,7 +1494,7 @@ dependencies = [ "tower-http", "tracing", "url", - "web-transport", + "web-transport-ws", ] [[package]] @@ -1672,9 +1767,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ "zerovec", ] @@ -1724,9 +1819,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.7" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", @@ -1735,7 +1830,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2", "thiserror 2.0.16", "tokio", "tracing", @@ -1744,14 +1839,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.11" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcbafbbdbb0f638fe3f35f3c56739f77a8a1d070cb25603226c83339b391472b" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "aws-lc-rs", "bytes", + "fastbloom", "getrandom 0.3.3", - "rand", + "lru-slab", + "rand 0.9.2", "ring", "rustc-hash 2.1.1", "rustls", @@ -1766,16 +1863,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.13" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1793,14 +1890,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1810,7 +1928,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", ] [[package]] @@ -1824,9 +1951,9 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.13.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +checksum = "0068c5b3cab1d4e271e0bb6539c87563c43411cad90b057b15c79958fbeb41f7" dependencies = [ "pem", "ring", @@ -2032,9 +2159,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +checksum = "be59af91596cac372a6942530653ad0c3a246cdd491aaa9dcaee47f88d67d5a0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -2047,7 +2174,7 @@ dependencies = [ "rustls-webpki", "security-framework", "security-framework-sys", - "webpki-root-certs 0.26.11", + "webpki-root-certs", "windows-sys 0.59.0", ] @@ -2196,9 +2323,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" dependencies = [ "serde", ] @@ -2247,6 +2374,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2283,6 +2421,12 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.11" @@ -2295,16 +2439,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.0" @@ -2428,12 +2562,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde", @@ -2443,15 +2576,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -2497,7 +2630,7 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2 0.6.0", + "socket2", "tokio-macros", "windows-sys 0.59.0", ] @@ -2523,6 +2656,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.26.2", +] + [[package]] name = "tokio-util" version = "0.7.16" @@ -2538,14 +2695,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" dependencies = [ + "indexmap 2.11.0", "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] @@ -2553,6 +2713,12 @@ name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" dependencies = [ "serde", ] @@ -2564,18 +2730,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.11.0", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", + "toml_datetime 0.6.11", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_parser" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" [[package]] name = "tower" @@ -2716,6 +2888,47 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.16", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + [[package]] name = "unicase" version = "2.8.1" @@ -2758,6 +2971,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2776,6 +2995,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -2803,11 +3028,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.3+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] @@ -2892,18 +3117,6 @@ dependencies = [ "wasm-bindgen-futures", ] -[[package]] -name = "web-streams" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c4d5dbf19463c4b65e974303d453cc11991873c7a4a4953214f791d73303a2" -dependencies = [ - "thiserror 2.0.16", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "web-sys" version = "0.3.77" @@ -2924,24 +3137,11 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-transport" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dd16497e7d916fe90639b0b8553bdb3098d488f576c3a825da852ed0e630d59" -dependencies = [ - "bytes", - "thiserror 2.0.16", - "url", - "web-transport-quinn", - "web-transport-wasm", -] - [[package]] name = "web-transport-proto" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1814af4572856a29a2d29a56520e86fda994423043b70139ce98e5a32e0d91be" +checksum = "fb650c577c46254d16041c7fe0dc9901d9a42df3f46e77e9d05d1b3c17294b19" dependencies = [ "bytes", "http", @@ -2951,9 +3151,9 @@ dependencies = [ [[package]] name = "web-transport-quinn" -version = "0.7.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678f219136b44edb0f264679f6668a41817ccb7cf9098256ff9a359cee8d010" +checksum = "aad296f2c132240811fa6783fbece2d90aada7b6b2081c22aeff533a4e695bd6" dependencies = [ "aws-lc-rs", "bytes", @@ -2968,22 +3168,31 @@ dependencies = [ "tokio", "url", "web-transport-proto", + "web-transport-trait", ] [[package]] -name = "web-transport-wasm" -version = "0.5.1" +name = "web-transport-trait" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3ad9b5e49988cf8f4fd722c1b390bad43482b866ac7a2ba0f23280eba74c893" +checksum = "4850148841799c83f033f4dddccb219f1f097aff6db1bda5b0d3be69fefb32bd" dependencies = [ "bytes", - "js-sys", +] + +[[package]] +name = "web-transport-ws" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7606375ad1582a0c60d3fe8f18e460a8e0cb6a31f37606a19b905c85b1329a" +dependencies = [ + "bytes", + "futures", "thiserror 2.0.16", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-streams", - "web-sys", + "tokio", + "tokio-tungstenite 0.24.0", + "web-transport-proto", + "web-transport-trait", ] [[package]] @@ -2996,15 +3205,6 @@ dependencies = [ "untrusted 0.9.0", ] -[[package]] -name = "webpki-root-certs" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" -dependencies = [ - "webpki-root-certs 1.0.2", -] - [[package]] name = "webpki-root-certs" version = "1.0.2" @@ -3326,13 +3526,10 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] +checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" [[package]] name = "writeable" diff --git a/rs/Cargo.toml b/rs/Cargo.toml index 284fb7bcc..634e93dad 100644 --- a/rs/Cargo.toml +++ b/rs/Cargo.toml @@ -12,13 +12,14 @@ members = [ resolver = "2" [workspace.dependencies] - hang = { version = "0.5", path = "hang" } moq-lite = { version = "0.6", path = "moq" } moq-native = { version = "0.7", path = "moq-native" } moq-token = { version = "0.5", path = "moq-token" } serde = { version = "1", features = ["derive"] } -tokio = "1.45" +tokio = "1.47" web-async = { version = "0.1.1", features = ["tracing"] } -web-transport = "0.9.4" +web-transport-quinn = { version = "0.8" } +web-transport-trait = { version = "0.1" } +web-transport-ws = { version = "0.1" } diff --git a/rs/hang-cli/src/server.rs b/rs/hang-cli/src/server.rs index ca56f72ea..c1aff5f4d 100644 --- a/rs/hang-cli/src/server.rs +++ b/rs/hang-cli/src/server.rs @@ -4,7 +4,7 @@ use axum::http::StatusCode; use axum::response::IntoResponse; use axum::{http::Method, routing::get, Router}; use hang::{cmaf, moq_lite}; -use moq_lite::web_transport; +use moq_native::web_transport_quinn; use std::net::SocketAddr; use std::path::PathBuf; use tokio::io::AsyncRead; @@ -66,7 +66,7 @@ async fn accept( #[tracing::instrument("session", skip_all, fields(id))] async fn run_session( id: u64, - session: web_transport::quinn::Request, + session: web_transport_quinn::Request, name: String, consumer: moq_lite::BroadcastConsumer, ) -> anyhow::Result<()> { diff --git a/rs/justfile b/rs/justfile index 4ff9f1cd9..4c6342bb6 100644 --- a/rs/justfile +++ b/rs/justfile @@ -135,9 +135,9 @@ clock action url="http://localhost:4443/anon": cargo run --bin moq-clock -- "{{url}}" --name "clock" {{action}} # Run the CI checks -check flags="": - cargo test --all-targets --all-features {{flags}} - cargo clippy --all-targets --all-features {{flags}} -- -D warnings +check: + cargo test --all-targets --all-features + cargo clippy --all-targets --all-features -- -D warnings cargo fmt --all --check # requires: cargo install cargo-shear @@ -147,9 +147,8 @@ check flags="": cargo sort --workspace --check # Automatically fix some issues. -fix flags="": - cargo fix --allow-staged --allow-dirty --all-targets --all-features {{flags}} - cargo clippy --fix --allow-staged --allow-dirty --all-targets --all-features {{flags}} +fix: + cargo clippy --fix --allow-staged --allow-dirty --all-targets --all-features cargo fmt --all # requires: cargo install cargo-shear diff --git a/rs/moq-native/Cargo.toml b/rs/moq-native/Cargo.toml index 623d766e2..d1167fab1 100644 --- a/rs/moq-native/Cargo.toml +++ b/rs/moq-native/Cargo.toml @@ -12,6 +12,7 @@ keywords = ["quic", "http3", "webtransport", "media", "live"] categories = ["multimedia", "network-programming", "web-programming"] [dependencies] + anyhow = { version = "1", features = ["backtrace"] } clap = { version = "4", features = ["derive", "env"] } futures = "0.3" @@ -19,7 +20,7 @@ hex = "0.4" moq-lite = { workspace = true } quinn = "0.11" -rcgen = "0.13" +rcgen = "0.14" reqwest = { version = "0.12", default-features = false } ring = "0.17" rustls = "0.23" @@ -32,5 +33,5 @@ tokio = { workspace = true, features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } url = "2" -web-transport = { workspace = true } +web-transport-quinn = { workspace = true } webpki = "0.22" diff --git a/rs/moq-native/src/client.rs b/rs/moq-native/src/client.rs index c5b93fc10..c980228ca 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -6,8 +6,6 @@ use std::path::PathBuf; use std::{fs, io, net, sync::Arc, time}; use url::Url; -use web_transport::quinn as web_transport_quinn; - #[derive(Clone, Default, Debug, clap::Args, serde::Serialize, serde::Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ClientTls { @@ -112,12 +110,11 @@ impl Client { let socket = std::net::UdpSocket::bind(config.bind).context("failed to bind UDP socket")?; - // Enable BBR congestion control - // TODO validate the implementation + // TODO Validate the BBR implementation before enabling it let mut transport = quinn::TransportConfig::default(); transport.max_idle_timeout(Some(time::Duration::from_secs(10).try_into().unwrap())); transport.keep_alive_interval(Some(time::Duration::from_secs(4))); - transport.congestion_controller_factory(Arc::new(quinn::congestion::BbrConfig::default())); + //transport.congestion_controller_factory(Arc::new(quinn::congestion::BbrConfig::default())); transport.mtu_discovery_config(None); // Disable MTU discovery let transport = Arc::new(transport); @@ -170,7 +167,7 @@ impl Client { } let alpn = match url.scheme() { - "https" => web_transport::quinn::ALPN, + "https" => web_transport_quinn::ALPN, "moql" => moq_lite::ALPN, _ => anyhow::bail!("url scheme must be 'http', 'https', or 'moql'"), }; @@ -189,8 +186,8 @@ impl Client { tracing::Span::current().record("id", connection.stable_id()); let session = match url.scheme() { - "https" => web_transport::quinn::Session::connect(connection, url).await?, - moq_lite::ALPN => web_transport::quinn::Session::raw(connection, url), + "https" => web_transport_quinn::Session::connect(connection, url).await?, + moq_lite::ALPN => web_transport_quinn::Session::raw(connection, url), _ => unreachable!(), }; diff --git a/rs/moq-native/src/lib.rs b/rs/moq-native/src/lib.rs index fa38063e5..7c625b2b8 100644 --- a/rs/moq-native/src/lib.rs +++ b/rs/moq-native/src/lib.rs @@ -8,4 +8,4 @@ pub use server::*; // Re-export these crates. pub use moq_lite; -pub use web_transport; +pub use web_transport_quinn; diff --git a/rs/moq-native/src/server.rs b/rs/moq-native/src/server.rs index 023818e2a..b495a5de2 100644 --- a/rs/moq-native/src/server.rs +++ b/rs/moq-native/src/server.rs @@ -14,8 +14,6 @@ use futures::future::BoxFuture; use futures::stream::{FuturesUnordered, StreamExt}; use futures::FutureExt; -use web_transport::quinn as web_transport_quinn; - #[derive(clap::Args, Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerTlsCert { @@ -37,14 +35,24 @@ impl ServerTlsCert { #[derive(clap::Args, Clone, Default, Debug, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerTlsConfig { - /// Load the given certificate and keys from disk. - #[arg(long = "tls-cert", value_parser = ServerTlsCert::parse, value_delimiter = ',', env = "MOQ_SERVER_TLS_CERT")] + /// Load the given certificate from disk. + #[arg(long = "tls-cert", id = "tls-cert", env = "MOQ_SERVER_TLS_CERT")] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cert: Vec, + + /// Load the given key from disk. + #[arg(long = "tls-key", id = "tls-key", env = "MOQ_SERVER_TLS_KEY")] #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub cert: Vec, + pub key: Vec, /// Or generate a new certificate and key with the given hostnames. /// This won't be valid unless the client uses the fingerprint or disables verification. - #[arg(long = "tls-generate", value_delimiter = ',', env = "MOQ_SERVER_TLS_GENERATE")] + #[arg( + long = "tls-generate", + id = "tls-generate", + value_delimiter = ',', + env = "MOQ_SERVER_TLS_GENERATE" + )] #[serde(default, skip_serializing_if = "Vec::is_empty")] pub generate: Vec, } @@ -77,11 +85,11 @@ pub struct Server { impl Server { pub fn new(config: ServerConfig) -> anyhow::Result { // Enable BBR congestion control - // TODO validate the implementation + // TODO Validate the BBR implementation before enabling it let mut transport = quinn::TransportConfig::default(); transport.max_idle_timeout(Some(Duration::from_secs(10).try_into().unwrap())); transport.keep_alive_interval(Some(Duration::from_secs(4))); - transport.congestion_controller_factory(Arc::new(quinn::congestion::BbrConfig::default())); + //transport.congestion_controller_factory(Arc::new(quinn::congestion::BbrConfig::default())); transport.mtu_discovery_config(None); // Disable MTU discovery let transport = Arc::new(transport); @@ -89,8 +97,13 @@ impl Server { let mut serve = ServeCerts::default(); // Load the certificate and key files based on their index. - for cert in &config.tls.cert { - serve.load(&cert.chain, &cert.key)?; + anyhow::ensure!( + config.tls.cert.len() == config.tls.key.len(), + "must provide both cert and key" + ); + + for (cert, key) in config.tls.cert.iter().zip(config.tls.key.iter()) { + serve.load(cert, key)?; } if !config.tls.generate.is_empty() { @@ -105,7 +118,7 @@ impl Server { .with_cert_resolver(Arc::new(serve)); tls.alpn_protocols = vec![ - web_transport::quinn::ALPN.as_bytes().to_vec(), + web_transport_quinn::ALPN.as_bytes().to_vec(), moq_lite::ALPN.as_bytes().to_vec(), ]; tls.key_log = Arc::new(rustls::KeyLogFile::new()); @@ -186,9 +199,9 @@ impl Server { span.record("id", conn.stable_id()); // TODO can we get this earlier? match alpn.as_str() { - web_transport::quinn::ALPN => { + web_transport_quinn::ALPN => { // Wait for the CONNECT request. - web_transport::quinn::Request::accept(conn) + web_transport_quinn::Request::accept(conn) .await .context("failed to receive WebTransport request") } @@ -212,7 +225,7 @@ struct ServeCerts { } impl ServeCerts { - // Load a certificate and cooresponding key from a file + // Load a certificate and corresponding key from a file pub fn load(&mut self, chain: &PathBuf, key: &PathBuf) -> anyhow::Result<()> { let chain = fs::File::open(chain).context("failed to open cert file")?; let mut chain = io::BufReader::new(chain); diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index e45e6c92f..807ea8fce 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -12,10 +12,12 @@ keywords = ["quic", "http3", "webtransport", "media", "live"] categories = ["multimedia", "network-programming", "web-programming"] [dependencies] + anyhow = { version = "1", features = ["backtrace"] } -axum = { version = "0.8", features = ["tokio"] } +axum = { version = "0.8", features = ["tokio", "ws"] } bytes = "1" clap = { version = "4", features = ["derive"] } +futures = "0.3" http-body = "1" hyper-serve = { version = "0.6", features = [ "tls-rustls", @@ -27,11 +29,11 @@ serde = { version = "1", features = ["derive"] } serde_with = { version = "3", features = ["json", "base64"] } thiserror = "2" tokio = { workspace = true, features = ["full"] } -toml = "0.8" +toml = "0.9" tower-http = { version = "0.6", features = ["cors"] } tracing = "0.1" url = { version = "2", features = ["serde"] } -web-transport = { workspace = true } +web-transport-ws = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/rs/moq-relay/cfg/dev.toml b/rs/moq-relay/cfg/dev.toml index 96074d544..e20a99801 100644 --- a/rs/moq-relay/cfg/dev.toml +++ b/rs/moq-relay/cfg/dev.toml @@ -15,6 +15,12 @@ listen = "[::]:4443" # This is used for local development, in conjunction with a fingerprint, or with TLS verification disabled. tls.generate = ["localhost"] +[web.http] +# Listen for HTTP and WebSocket connections on the given TCP address. +# This is unfortunately required to serve certificate.sha256 for local development. +# However, as a bonus, we can serve tracks via both HTTP and WebSocket fallbacks. +listen = "[::]:4443" + # See root.toml and leaf.toml for auth and clustering examples. [auth] # Allow anonymous access to everything. diff --git a/rs/moq-relay/cfg/leaf.toml b/rs/moq-relay/cfg/leaf.toml index 04eed2f33..925d0e05b 100644 --- a/rs/moq-relay/cfg/leaf.toml +++ b/rs/moq-relay/cfg/leaf.toml @@ -4,14 +4,18 @@ level = "debug" [server] -# Listen for QUIC connections on UDP:4443 -# Sometimes IPv6 causes issues; try 127.0.0.1:4443 instead. +# Listen for QUIC connections on UDP:4444 +# Sometimes IPv6 causes issues; try 127.0.0.1:4444 instead. listen = "[::]:4444" # Generate a self-signed certificate for the server. # You should use a real certificate in production. tls.generate = ["localhost"] +[web.http] +# Listen for HTTP and WebSocket (TCP) connections on the given address. +listen = "[::]:4444" + # This clustering scheme is very very simple for now. # # There is a root node that is used to connect leaf nodes together. diff --git a/rs/moq-relay/cfg/prod.toml b/rs/moq-relay/cfg/prod.toml new file mode 100644 index 000000000..8600f6226 --- /dev/null +++ b/rs/moq-relay/cfg/prod.toml @@ -0,0 +1,30 @@ +# An example of a production configuration. +# Here's a real one: https://github.com/kixelated/moq.dev/blob/b4da7dd8f09879cefb593d5e35f99f064caa3943/infra/relay.yml.tpl#L56 + +[server] +# Listen for QUIC connections on UDP:443 +# This is the default port for WebTransport +listen = "[::]:443" + +[server.tls] +# You'll need to provide a real certificate and key in production. +# You can get this via a service like Let's Encrypt. +cert = "cert.pem" +key = "key.pem" + +#[web.http] +# Optionally listen for HTTP and WebSocket connections on port 80. +# listen = "[::]:80" + +[web.https] +# Optionally listen for TCP/HTTPS/WSS connections on port 443. +listen = "[::]:443" + +# You'll need to provide a real certificate and key in production. +# This can be the exact same certificate and key as the QUIC endpoint. +cert = "cert.pem" +key = "key.pem" + +# See docs/auth.md for more information. +[auth] +key = "root.jwk" diff --git a/rs/moq-relay/cfg/root.toml b/rs/moq-relay/cfg/root.toml index 103d68635..8b8140de1 100644 --- a/rs/moq-relay/cfg/root.toml +++ b/rs/moq-relay/cfg/root.toml @@ -12,6 +12,11 @@ listen = "[::]:4443" # This is used for local development, in conjunction with a fingerprint, or with TLS verification disabled. tls.generate = ["localhost"] +[web.http] +# Listen for HTTP and WebSocket (TCP) connections on the given address. +# Defaults to disabled if not provided. +listen = "[::]:4443" + # In production, we would use a real certificate from something like Let's Encrypt. # Multiple certificates are supported; the first one that matches the SNI will be used. # [[server.tls.cert]] diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 0f0cc447b..378e6d75f 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -1,9 +1,35 @@ use std::sync::Arc; -use anyhow::Context; +use axum::http; use moq_lite::{AsPath, Path, PathOwned}; use serde::{Deserialize, Serialize}; -use url::Url; + +#[derive(thiserror::Error, Debug, Clone)] +pub enum AuthError { + #[error("authentication is disabled")] + UnexpectedToken, + + #[error("a token was expected")] + ExpectedToken, + + #[error("failed to decode the token")] + DecodeFailed, + + #[error("the path does not match the root")] + IncorrectRoot, +} + +impl From for http::StatusCode { + fn from(_: AuthError) -> Self { + http::StatusCode::UNAUTHORIZED + } +} + +impl axum::response::IntoResponse for AuthError { + fn into_response(self) -> axum::response::Response { + http::StatusCode::UNAUTHORIZED.into_response() + } +} #[derive(clap::Args, Clone, Debug, Serialize, Deserialize, Default)] #[serde(default)] @@ -63,14 +89,14 @@ impl Auth { // Parse the token from the user provided URL, returning the claims if successful. // If no token is provided, then the claims will use the public path if it is set. - pub fn verify(&self, url: &Url) -> anyhow::Result { + pub fn verify(&self, path: &str, token: Option<&str>) -> Result { // Find the token in the query parameters. // ?jwt=... - let claims = if let Some((_, token)) = url.query_pairs().find(|(k, _)| k == "jwt") { + let claims = if let Some(token) = token { if let Some(key) = self.key.as_ref() { - key.decode(&token)? + key.decode(token).map_err(|_| AuthError::DecodeFailed)? } else { - anyhow::bail!("token provided, but no key configured"); + return Err(AuthError::UnexpectedToken); } } else if let Some(public) = &self.public { moq_token::Claims { @@ -80,17 +106,18 @@ impl Auth { ..Default::default() } } else { - anyhow::bail!("no token provided and no public path configured"); + return Err(AuthError::ExpectedToken); }; // Get the path from the URL, removing any leading or trailing slashes. // We will automatically add a trailing slash when joining the path with the subscribe/publish roots. - let root = Path::new(url.path()); + let root = Path::new(path); // Make sure the URL path matches the root path. - let suffix = root - .strip_prefix(&claims.root) - .context("path does not match the root")?; + let suffix = match root.strip_prefix(&claims.root) { + None => return Err(AuthError::IncorrectRoot), + Some(suffix) => suffix, + }; // If a more specific path is is provided, reduce the permissions. let subscribe = claims @@ -150,15 +177,13 @@ mod tests { })?; // Should succeed for anonymous path - let url = Url::parse("https://relay.example.com/anon")?; - let token = auth.verify(&url)?; + let token = auth.verify("/anon", None)?; assert_eq!(token.root, "anon".as_path()); assert_eq!(token.subscribe, vec!["".as_path()]); assert_eq!(token.publish, vec!["".as_path()]); // Should succeed for sub-paths under anonymous - let url = Url::parse("https://relay.example.com/anon/room/123")?; - let token = auth.verify(&url)?; + let token = auth.verify("/anon/room/123", None)?; assert_eq!(token.root, Path::new("anon/room/123").to_owned()); assert_eq!(token.subscribe, vec![Path::new("").to_owned()]); assert_eq!(token.publish, vec![Path::new("").to_owned()]); @@ -175,8 +200,7 @@ mod tests { })?; // Should succeed for any path - let url = Url::parse("https://relay.example.com/any/path")?; - let token = auth.verify(&url)?; + let token = auth.verify("/any/path", None)?; assert_eq!(token.root, Path::new("any/path").to_owned()); assert_eq!(token.subscribe, vec![Path::new("").to_owned()]); assert_eq!(token.publish, vec![Path::new("").to_owned()]); @@ -193,10 +217,8 @@ mod tests { })?; // Should fail for non-anonymous path - let url = Url::parse("https://relay.example.com/secret")?; - let result = auth.verify(&url); + let result = auth.verify("/secret", None); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("path does not match the root")); Ok(()) } @@ -210,13 +232,8 @@ mod tests { })?; // Should fail when no token and no public path - let url = Url::parse("https://relay.example.com/any/path")?; - let result = auth.verify(&url); + let result = auth.verify("/any/path", None); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("no token provided and no public path configured")); Ok(()) } @@ -229,13 +246,8 @@ mod tests { })?; // Should fail when token provided but no key configured - let url = Url::parse("https://relay.example.com/any/path?jwt=fake-token")?; - let result = auth.verify(&url); + let result = auth.verify("/any/path", Some("fake-token")); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("token provided, but no key configured")); Ok(()) } @@ -258,8 +270,7 @@ mod tests { let token = key.encode(&claims)?; // Should succeed with valid token and matching path - let url = Url::parse(&format!("https://relay.example.com/room/123?jwt={token}"))?; - let token = auth.verify(&url)?; + let token = auth.verify("/room/123", Some(&token))?; assert_eq!(token.root, "room/123".as_path()); assert_eq!(token.subscribe, vec!["".as_path()]); assert_eq!(token.publish, vec!["alice".as_path()]); @@ -285,10 +296,8 @@ mod tests { let token = key.encode(&claims)?; // Should fail when trying to access wrong path - let url = Url::parse(&format!("https://relay.example.com/secret?jwt={token}"))?; - let result = auth.verify(&url); + let result = auth.verify("/secret", Some(&token)); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("path does not match the root")); Ok(()) } @@ -311,8 +320,7 @@ mod tests { let token = key.encode(&claims)?; // Verify the restrictions are preserved - let url = Url::parse(&format!("https://relay.example.com/room/123?jwt={token}"))?; - let token = auth.verify(&url)?; + let token = auth.verify("/room/123", Some(&token))?; assert_eq!(token.root, "room/123".as_path()); assert_eq!(token.subscribe, vec!["bob".as_path()]); assert_eq!(token.publish, vec!["alice".as_path()]); @@ -337,8 +345,7 @@ mod tests { }; let token = key.encode(&claims)?; - let url = Url::parse(&format!("https://relay.example.com/room/123?jwt={token}"))?; - let token = auth.verify(&url)?; + let token = auth.verify("/room/123", Some(&token))?; assert_eq!(token.subscribe, vec!["".as_path()]); assert_eq!(token.publish, vec![]); @@ -362,8 +369,7 @@ mod tests { }; let token = key.encode(&claims)?; - let url = Url::parse(&format!("https://relay.example.com/room/123?jwt={token}"))?; - let token = auth.verify(&url)?; + let token = auth.verify("/room/123", Some(&token))?; assert_eq!(token.subscribe, vec![]); assert_eq!(token.publish, vec!["bob".as_path()]); @@ -388,8 +394,7 @@ mod tests { let token = key.encode(&claims)?; // Connect to more specific path room/123/alice - let url = Url::parse(&format!("https://relay.example.com/room/123/alice?jwt={token}"))?; - let token = auth.verify(&url)?; + let token = auth.verify("/room/123/alice", Some(&token))?; // Root should be updated to the more specific path assert_eq!(token.root, Path::new("room/123/alice")); @@ -418,8 +423,7 @@ mod tests { let token = key.encode(&claims)?; // Connect to room/123/alice - should remove alice prefix from publish - let url = Url::parse(&format!("https://relay.example.com/room/123/alice?jwt={token}"))?; - let token = auth.verify(&url)?; + let token = auth.verify("/room/123/alice", Some(&token))?; assert_eq!(token.root, "room/123/alice".as_path()); // Alice still can't subscribe to anything. @@ -448,8 +452,7 @@ mod tests { let token = key.encode(&claims)?; // Connect to room/123/bob - should remove bob prefix from subscribe - let url = Url::parse(&format!("https://relay.example.com/room/123/bob?jwt={token}"))?; - let token = auth.verify(&url)?; + let token = auth.verify("/room/123/bob", Some(&token))?; assert_eq!(token.root, "room/123/bob".as_path()); // bob prefix stripped, now can subscribe to everything under room/123/bob @@ -477,8 +480,7 @@ mod tests { let token = key.encode(&claims)?; // Connect to room/123/alice - loses ability to subscribe to bob - let url = Url::parse(&format!("https://relay.example.com/room/123/alice?jwt={token}"))?; - let verified = auth.verify(&url)?; + let verified = auth.verify("/room/123/alice", Some(&token))?; assert_eq!(verified.root, "room/123/alice".as_path()); // Can't subscribe to bob anymore (alice doesn't have bob prefix) @@ -487,8 +489,7 @@ mod tests { assert_eq!(verified.publish, vec!["".as_path()]); // Connect to room/123/bob - loses ability to publish to alice - let url = Url::parse(&format!("https://relay.example.com/room/123/bob?jwt={token}"))?; - let token = auth.verify(&url)?; + let token = auth.verify("/room/123/bob", Some(&token))?; assert_eq!(token.root, "room/123/bob".as_path()); // Can subscribe to everything under bob @@ -517,8 +518,7 @@ mod tests { let token = key.encode(&claims)?; // Connect to room/123/users - permissions should be reduced - let url = Url::parse(&format!("https://relay.example.com/room/123/users?jwt={token}"))?; - let verified = auth.verify(&url)?; + let verified = auth.verify("/room/123/users", Some(&token))?; assert_eq!(verified.root, "room/123/users".as_path()); // users prefix removed from paths @@ -526,8 +526,7 @@ mod tests { assert_eq!(verified.publish, vec!["alice/camera".as_path()]); // Connect to room/123/users/alice - further reduction - let url = Url::parse(&format!("https://relay.example.com/room/123/users/alice?jwt={token}"))?; - let token = auth.verify(&url)?; + let token = auth.verify("/room/123/users/alice", Some(&token))?; assert_eq!(token.root, "room/123/users/alice".as_path()); // Can't subscribe (alice doesn't have bob prefix) @@ -556,8 +555,7 @@ mod tests { let token = key.encode(&claims)?; // Connect to more specific path - let url = Url::parse(&format!("https://relay.example.com/room/123/alice?jwt={token}"))?; - let token = auth.verify(&url)?; + let token = auth.verify("/room/123/alice", Some(&token))?; // Should remain read-only assert_eq!(token.subscribe, vec!["".as_path()]); @@ -572,8 +570,7 @@ mod tests { }; let token = key.encode(&claims)?; - let url = Url::parse(&format!("https://relay.example.com/room/123/alice?jwt={token}"))?; - let verified = auth.verify(&url)?; + let verified = auth.verify("/room/123/alice", Some(&token))?; // Should remain write-only assert_eq!(verified.subscribe, vec![]); diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index 332ced91f..5e3c7ad1a 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -5,6 +5,8 @@ use moq_lite::{AsPath, Broadcast, BroadcastConsumer, BroadcastProducer, Origin, use tracing::Instrument; use url::Url; +use crate::AuthToken; + #[serde_with::serde_as] #[derive(clap::Args, Clone, Debug, serde::Serialize, serde::Deserialize, Default)] #[serde_with::skip_serializing_none] @@ -64,6 +66,33 @@ impl Cluster { } } + // For a given auth token, return the origin that should be used for the session. + pub fn subscriber(&self, token: &AuthToken) -> Option { + // These broadcasts will be served to the session (when it subscribes). + // If this is a cluster node, then only publish our primary broadcasts. + // Otherwise publish everything. + let subscribe_origin = match token.cluster { + true => &self.primary, + false => &self.combined, + }; + + // Scope the origin to our root. + let subscribe_origin = subscribe_origin.producer.with_root(&token.root)?; + subscribe_origin.consume_only(&token.subscribe) + } + + pub fn publisher(&self, token: &AuthToken) -> Option { + // If this is a cluster node, then add its broadcasts to the secondary origin. + // That way we won't publish them to other cluster nodes. + let publish_origin = match token.cluster { + true => &self.secondary, + false => &self.primary, + }; + + let publish_origin = publish_origin.producer.with_root(&token.root)?; + publish_origin.publish_only(&token.publish) + } + pub fn get(&self, broadcast: &str) -> Option { self.primary .consumer diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index ab451f6a0..e0e9c76d1 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -1,7 +1,7 @@ use clap::Parser; use serde::{Deserialize, Serialize}; -use crate::{AuthConfig, ClusterConfig}; +use crate::{AuthConfig, ClusterConfig, WebConfig}; #[derive(Parser, Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -30,6 +30,11 @@ pub struct Config { #[serde(default)] pub auth: AuthConfig, + /// Optionally run a TCP HTTP/WebSocket server. + #[command(flatten)] + #[serde(default)] + pub web: WebConfig, + /// If provided, load the configuration from this file. #[serde(default)] pub file: Option, diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index 68e848f2a..eefa6bbfd 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -1,10 +1,10 @@ use crate::{Auth, Cluster}; -use web_transport::quinn::http; +use moq_native::web_transport_quinn; pub struct Connection { pub id: u64, - pub request: web_transport::quinn::Request, + pub request: web_transport_quinn::Request, pub cluster: Cluster, pub auth: Auth, } @@ -12,55 +12,43 @@ pub struct Connection { impl Connection { #[tracing::instrument("conn", skip_all, fields(id = self.id))] pub async fn run(self) -> anyhow::Result<()> { + // Extract the path and token from the URL. + let path = self.request.url().path(); + let token = self + .request + .url() + .query_pairs() + .find(|(k, _)| k == "jwt") + .map(|(_, v)| v.to_string()); + // Verify the URL before accepting the connection. - let token = match self.auth.verify(self.request.url()) { + let token = match self.auth.verify(path, token.as_deref()) { Ok(token) => token, Err(err) => { - self.request.close(http::StatusCode::UNAUTHORIZED).await?; - return Err(err); + let _ = self.request.close(err.clone().into()).await; + return Err(err.into()); } }; - tracing::info!(token = ?token, "session accepted"); - - // Accept the connection. - let session = self.request.ok().await?; - - // These broadcasts will be served to the session (when it subscribes). - // If this is a cluster node, then only publish our primary broadcasts. - // Otherwise publish everything. - let subscribe_origin = match token.cluster { - true => &self.cluster.primary, - false => &self.cluster.combined, - }; - - // Scope the origin to our root. - let subscribe_origin = subscribe_origin.producer.with_root(&token.root).unwrap(); - let subscribe = subscribe_origin.consume_only(&token.subscribe); + let publish = self.cluster.publisher(&token); + let subscribe = self.cluster.subscriber(&token); - // If this is a cluster node, then add its broadcasts to the secondary origin. - // That way we won't publish them to other cluster nodes. - let publish_origin = match token.cluster { - true => &self.cluster.secondary, - false => &self.cluster.primary, - }; - - let publish_origin = publish_origin.producer.with_root(&token.root).unwrap(); - let publish = publish_origin.publish_only(&token.publish); - - match (&subscribe, &publish) { - (Some(subscribe), Some(publish)) => { - tracing::info!(root = %token.root, subscribe = %subscribe.allowed().map(|p| p.as_str()).collect::>().join(","), publish = %publish.allowed().map(|p| p.as_str()).collect::>().join(","), "session accepted"); + match (&publish, &subscribe) { + (Some(publish), Some(subscribe)) => { + tracing::info!(root = %token.root, publish = %publish.allowed().map(|p| p.as_str()).collect::>().join(","), subscribe = %subscribe.allowed().map(|p| p.as_str()).collect::>().join(","), "session accepted"); } - (Some(subscribe), None) => { - tracing::info!(root = %token.root, subscribe = %subscribe.allowed().map(|p| p.as_str()).collect::>().join(","), "subscriber accepted"); + (Some(publish), None) => { + tracing::info!(root = %token.root, publish = %publish.allowed().map(|p| p.as_str()).collect::>().join(","), "publisher accepted"); } - (None, Some(publish)) => { - tracing::info!(root = %token.root, publish = %publish.allowed().map(|p| p.as_str()).collect::>().join(","), "publisher accepted") + (None, Some(subscribe)) => { + tracing::info!(root = %token.root, subscribe = %subscribe.allowed().map(|p| p.as_str()).collect::>().join(","), "subscriber accepted") } _ => anyhow::bail!("invalid session; no allowed paths"), } + // Accept the connection. + let session = self.request.ok().await?; + // NOTE: subscribe and publish seem backwards because of how relays work. // We publish the tracks the client is allowed to subscribe to. // We subscribe to the tracks the client is allowed to publish. diff --git a/rs/moq-relay/src/main.rs b/rs/moq-relay/src/main.rs index bc03c1e8f..027e3a2ee 100644 --- a/rs/moq-relay/src/main.rs +++ b/rs/moq-relay/src/main.rs @@ -25,11 +25,15 @@ async fn main() -> anyhow::Result<()> { tokio::spawn(async move { cloned.run().await.expect("cluster failed") }); // Create a web server too. - let web = Web::new(WebConfig { - bind: addr, - fingerprints, - cluster: cluster.clone(), - }); + let web = Web::new( + WebState { + auth: auth.clone(), + cluster: cluster.clone(), + fingerprints, + conn_id: Default::default(), + }, + config.web, + ); tokio::spawn(async move { web.run().await.expect("failed to run web server"); diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 49c788d69..8779401ff 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -1,82 +1,207 @@ +use futures::{SinkExt, StreamExt}; use std::{ net, + path::PathBuf, pin::Pin, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, task::{ready, Context, Poll}, }; +use web_transport_ws::tungstenite; use axum::{ body::Body, - extract::Path, + extract::{Path, Query, State, WebSocketUpgrade}, http::{Method, StatusCode}, response::{IntoResponse, Response}, - routing::get, + routing::{any, get}, Router, }; use bytes::Bytes; -use hyper_serve::accept::DefaultAcceptor; +use clap::Parser; +use moq_lite::{OriginConsumer, OriginProducer}; +use serde::{Deserialize, Serialize}; use std::future::Future; use tower_http::cors::{Any, CorsLayer}; -use crate::Cluster; +use crate::{Auth, Cluster}; +#[derive(Debug, Deserialize)] +struct Params { + jwt: Option, +} + +#[derive(Parser, Clone, Debug, Deserialize, Serialize, Default)] +#[serde(deny_unknown_fields, default)] pub struct WebConfig { - pub bind: net::SocketAddr, - pub fingerprints: Vec, + #[command(flatten)] + #[serde(default)] + pub http: HttpConfig, + + #[command(flatten)] + #[serde(default)] + pub https: HttpsConfig, + + // If true (default), expose a WebTransport compatible WebSocket polyfill. + #[arg(long = "web-ws", env = "MOQ_WEB_WS", default_value = "true")] + #[serde(default = "default_true")] + pub ws: bool, +} + +#[derive(clap::Args, Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct HttpConfig { + #[arg(long = "web-http-listen", id = "http-listen", env = "MOQ_WEB_HTTP_LISTEN")] + pub listen: Option, +} + +#[derive(clap::Args, Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct HttpsConfig { + #[arg(long = "web-https-listen", id = "web-https-listen", env = "MOQ_WEB_HTTPS_LISTEN", requires_all = ["web-https-cert", "web-https-key"])] + pub listen: Option, + + /// Load the given certificate from disk. + #[arg(long = "web-https-cert", id = "web-https-cert", env = "MOQ_WEB_HTTPS_CERT")] + pub cert: Option, + + /// Load the given key from disk. + #[arg(long = "web-https-key", id = "web-https-key", env = "MOQ_WEB_HTTPS_KEY")] + pub key: Option, +} + +pub struct WebState { + pub auth: Auth, pub cluster: Cluster, + pub fingerprints: Vec, + pub conn_id: AtomicU64, } // Run a HTTP server using Axum -// TODO remove this when Chrome adds support for self-signed certificates using WebTransport pub struct Web { - app: Router, - server: hyper_serve::Server, + state: WebState, + config: WebConfig, } impl Web { - pub fn new(config: WebConfig) -> Self { + pub fn new(state: WebState, config: WebConfig) -> Self { + Self { state, config } + } + + pub async fn run(self) -> anyhow::Result<()> { // Get the first certificate's fingerprint. // TODO serve all of them so we can support multiple signature algorithms. - let fingerprint = config.fingerprints.first().expect("missing certificate").clone(); + let fingerprint = self.state.fingerprints.first().expect("missing certificate").clone(); let app = Router::new() .route("/certificate.sha256", get(fingerprint)) - .route( - "/announced", - get({ - let cluster = config.cluster.clone(); - move || serve_announced(Path("".to_string()), cluster.clone()) - }), - ) - .route( - "/announced/{*prefix}", - get({ - let cluster = config.cluster.clone(); - move |path| serve_announced(path, cluster) - }), - ) - .route( - "/fetch/{*path}", - get({ - let cluster = config.cluster.clone(); - move |path| serve_fetch(path, cluster) - }), - ) - .layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET])); - - let server = hyper_serve::bind(config.bind); - - Self { app, server } - } + .route("/announced", get(serve_announced)) + .route("/announced/{*prefix}", get(serve_announced)) + .route("/fetch/{*path}", get(serve_fetch)); + + // If WebSocket is enabled, add the WebSocket route. + let app = match self.config.ws { + true => app.route("/{*path}", any(serve_ws)), + false => app, + } + .layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET])) + .with_state(Arc::new(self.state)) + .into_make_service(); + + let http = if let Some(listen) = self.config.http.listen { + let server = hyper_serve::bind(listen); + Some(server.serve(app.clone())) + } else { + None + }; + + let https = if let Some(listen) = self.config.https.listen { + let cert = self.config.https.cert.as_ref().expect("missing certificate"); + let key = self.config.https.key.as_ref().expect("missing key"); + + let config = hyper_serve::tls_rustls::RustlsConfig::from_pem_file(cert, key).await?; + + let server = hyper_serve::bind_rustls(listen, config); + Some(server.serve(app)) + } else { + None + }; + + tokio::select! { + Some(res) = async move { Some(http?.await) } => res?, + Some(res) = async move { Some(https?.await) } => res?, + else => {}, + }; - pub async fn run(self) -> anyhow::Result<()> { - self.server.serve(self.app.into_make_service()).await?; Ok(()) } } +async fn serve_ws( + ws: WebSocketUpgrade, + Path(path): Path, + Query(params): Query, + State(state): State>, +) -> axum::response::Result { + let ws = ws.protocols(["webtransport"]); + + let token = state.auth.verify(&path, params.jwt.as_deref())?; + let publish = state.cluster.publisher(&token); + let subscribe = state.cluster.subscriber(&token); + + if publish.is_none() && subscribe.is_none() { + // Bad token, we can't publish or subscribe. + return Err(StatusCode::UNAUTHORIZED.into()); + } + + Ok(ws.on_upgrade(async move |socket| { + let id = state.conn_id.fetch_add(1, Ordering::Relaxed); + + // Unfortunately, we need to convert from Axum to Tungstenite. + // Axum uses Tungstenite internally, but it's not exposed to avoid semvar issues. + let socket = socket + .map(axum_to_tungstenite) + // TODO Figure out how to avoid swallowing errors. + .sink_map_err(|err| { + tracing::warn!(%err, "WebSocket error"); + tungstenite::Error::ConnectionClosed + }) + .with(tungstenite_to_axum); + let _ = handle_socket(id, socket, publish, subscribe).await; + })) +} + +#[tracing::instrument("ws", err, skip_all, fields(id = _id))] +async fn handle_socket( + _id: u64, + socket: T, + publish: Option, + subscribe: Option, +) -> anyhow::Result<()> +where + T: futures::Stream> + + futures::Sink + + Send + + Unpin + + 'static, +{ + // Wrap the WebSocket in a WebTransport compatibility layer. + let ws = web_transport_ws::Session::new(socket, true); + let session = moq_lite::Session::accept(ws, subscribe, publish).await?; + Err(session.closed().await.into()) +} + /// Serve the announced broadcasts for a given prefix. -async fn serve_announced(Path(prefix): Path, cluster: Cluster) -> axum::response::Result { - let mut origin = match cluster.combined.consumer.consume_only(&[prefix.into()]) { +async fn serve_announced( + Path(prefix): Path>, + Query(params): Query, + State(state): State>, +) -> axum::response::Result { + let prefix = prefix.unwrap_or_default(); + let token = state.auth.verify(&prefix, params.jwt.as_deref())?; + let mut origin = match state.cluster.subscriber(&token) { Some(origin) => origin, None => return Err(StatusCode::UNAUTHORIZED.into()), }; @@ -93,14 +218,27 @@ async fn serve_announced(Path(prefix): Path, cluster: Cluster) -> axum:: } /// Serve the latest group for a given track -async fn serve_fetch(Path(path): Path, cluster: Cluster) -> axum::response::Result { +async fn serve_fetch( + Path(path): Path, + Query(params): Query, + State(state): State>, +) -> axum::response::Result { + // The path containts a broadcast/track let mut path: Vec<&str> = path.split("/").collect(); - if path.len() < 2 { + let track = path.pop().unwrap().to_string(); + + // We need at least a broadcast and a track. + if path.is_empty() { return Err(StatusCode::BAD_REQUEST.into()); } - let track = path.pop().unwrap().to_string(); let broadcast = path.join("/"); + let token = state.auth.verify(&broadcast, params.jwt.as_deref())?; + + let origin = match state.cluster.subscriber(&token) { + Some(origin) => origin, + None => return Err(StatusCode::UNAUTHORIZED.into()), + }; tracing::info!(%broadcast, %track, "subscribing to track"); @@ -109,7 +247,7 @@ async fn serve_fetch(Path(path): Path, cluster: Cluster) -> axum::respon priority: 0, }; - let broadcast = cluster.get(&broadcast).ok_or(StatusCode::NOT_FOUND)?; + let broadcast = origin.consume_broadcast(&broadcast).ok_or(StatusCode::NOT_FOUND)?; let mut track = broadcast.subscribe_track(&track); let group = match track.next_group().await { @@ -187,3 +325,51 @@ impl IntoResponse for ServeGroupError { (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response() } } + +// https://github.com/tokio-rs/axum/discussions/848#discussioncomment-11443587 + +#[allow(clippy::result_large_err)] +fn axum_to_tungstenite( + message: Result, +) -> Result { + match message { + Ok(msg) => Ok(match msg { + axum::extract::ws::Message::Text(text) => tungstenite::Message::Text(text.to_string()), + axum::extract::ws::Message::Binary(bin) => tungstenite::Message::Binary(bin.into()), + axum::extract::ws::Message::Ping(ping) => tungstenite::Message::Ping(ping.into()), + axum::extract::ws::Message::Pong(pong) => tungstenite::Message::Pong(pong.into()), + axum::extract::ws::Message::Close(close) => { + tungstenite::Message::Close(close.map(|c| tungstenite::protocol::CloseFrame { + code: c.code.into(), + reason: c.reason.to_string().into(), + })) + } + }), + Err(_err) => Err(tungstenite::Error::ConnectionClosed), + } +} + +#[allow(clippy::result_large_err)] +fn tungstenite_to_axum( + message: tungstenite::Message, +) -> Pin> + Send + Sync>> { + Box::pin(async move { + Ok(match message { + tungstenite::Message::Text(text) => axum::extract::ws::Message::Text(text.into()), + tungstenite::Message::Binary(bin) => axum::extract::ws::Message::Binary(bin.into()), + tungstenite::Message::Ping(ping) => axum::extract::ws::Message::Ping(ping.into()), + tungstenite::Message::Pong(pong) => axum::extract::ws::Message::Pong(pong.into()), + tungstenite::Message::Frame(_frame) => unreachable!(), + tungstenite::Message::Close(close) => { + axum::extract::ws::Message::Close(close.map(|c| axum::extract::ws::CloseFrame { + code: c.code.into(), + reason: c.reason.to_string().into(), + })) + } + }) + }) +} + +fn default_true() -> bool { + true +} diff --git a/rs/moq/Cargo.toml b/rs/moq/Cargo.toml index beb1c99e5..d0742c7d8 100644 --- a/rs/moq/Cargo.toml +++ b/rs/moq/Cargo.toml @@ -29,4 +29,4 @@ tokio = { workspace = true, features = [ ] } tracing = "0.1" web-async = { workspace = true } -web-transport = { workspace = true } +web-transport-trait = { workspace = true } diff --git a/rs/moq/src/coding/varint.rs b/rs/moq/src/coding/varint.rs index 1c034a198..8cc92929a 100644 --- a/rs/moq/src/coding/varint.rs +++ b/rs/moq/src/coding/varint.rs @@ -18,7 +18,7 @@ pub struct BoundsExceeded; /// Values of this type are suitable for encoding as QUIC variable-length integer. /// It would be neat if we could express to Rust that the top two bits are available for use as enum /// discriminants -#[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct VarInt(u64); impl VarInt { @@ -203,11 +203,11 @@ impl Decode for VarInt { impl Encode for VarInt { /// Encode a varint to the given writer. fn encode(&self, w: &mut W) { - if self.0 < 2u64.pow(6) { + if self.0 < (1u64 << 6) { w.put_u8(self.0 as u8); - } else if self.0 < 2u64.pow(14) { + } else if self.0 < (1u64 << 14) { w.put_u16((0b01 << 14) | self.0 as u16); - } else if self.0 < 2u64.pow(30) { + } else if self.0 < (1u64 << 30) { w.put_u32((0b10 << 30) | self.0 as u32); } else { w.put_u64((0b11 << 62) | self.0); diff --git a/rs/moq/src/error.rs b/rs/moq/src/error.rs index 8df201a52..8f680bffb 100644 --- a/rs/moq/src/error.rs +++ b/rs/moq/src/error.rs @@ -1,10 +1,12 @@ +use std::sync::Arc; + use crate::{coding, message}; /// A list of possible errors that can occur during the session. #[derive(thiserror::Error, Debug, Clone)] pub enum Error { - #[error("webtransport error: {0}")] - WebTransport(#[from] web_transport::Error), + #[error("transport error: {0}")] + Transport(Arc), #[error("decode error: {0}")] Decode(#[from] coding::DecodeError), @@ -67,7 +69,7 @@ impl Error { Self::RequiredExtension(_) => 1, Self::Old => 2, Self::Timeout => 3, - Self::WebTransport(_) => 4, + Self::Transport(_) => 4, Self::Decode(_) => 5, Self::Unauthorized => 6, Self::Version(..) => 9, diff --git a/rs/moq/src/lib.rs b/rs/moq/src/lib.rs index 6cbe69e46..5b9636e30 100644 --- a/rs/moq/src/lib.rs +++ b/rs/moq/src/lib.rs @@ -21,6 +21,7 @@ mod session; pub mod coding; pub mod message; + pub use error::*; pub use model::*; pub use path::*; @@ -28,6 +29,3 @@ pub use session::*; /// The ALPN used when connecting via QUIC directly. pub const ALPN: &str = message::Alpn::CURRENT.0; - -/// Export the web_transport crate. -pub use web_transport; diff --git a/rs/moq/src/session/mod.rs b/rs/moq/src/session/mod.rs index aebd759b0..a0f44a8a2 100644 --- a/rs/moq/src/session/mod.rs +++ b/rs/moq/src/session/mod.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use crate::{message, Error, OriginConsumer, OriginProducer}; mod publisher; @@ -17,14 +19,14 @@ use writer::*; /// /// This simplifies the state machine and immediately rejects any subscriptions that don't match the origin prefix. /// You probably want to use [Session] unless you're writing a relay. -pub struct Session { - pub webtransport: web_transport::Session, +pub struct Session { + transport: S, } -impl Session { +impl Session { async fn new( - mut session: web_transport::Session, - stream: Stream, + session: S, + stream: Stream, // We will publish any local broadcasts from this origin. publish: Option, // We will consume any remote broadcasts, inserting them into this origin. @@ -34,7 +36,7 @@ impl Session { let subscriber = Subscriber::new(session.clone(), subscribe); let this = Self { - webtransport: session.clone(), + transport: session.clone(), }; let init = oneshot::channel(); @@ -47,13 +49,13 @@ impl Session { }; match res { - Err(Error::WebTransport(web_transport::Error::Session(_))) => { + Err(Error::Transport(_)) => { tracing::info!("session terminated"); session.close(1, ""); } Err(err) => { tracing::warn!(%err, "session error"); - session.close(err.to_code(), &err.to_string()); + session.close(err.to_code(), err.to_string().as_ref()); } _ => { tracing::info!("session closed"); @@ -73,18 +75,17 @@ impl Session { /// Perform the MoQ handshake as a client. pub async fn connect( - session: impl Into, + session: S, publish: impl Into>, subscribe: impl Into>, ) -> Result { - let mut session = session.into(); - let mut stream = Stream::open(&mut session, message::ControlType::Session).await?; + let mut stream = Stream::open(&session, message::ControlType::Session).await?; Self::connect_setup(&mut stream).await?; let session = Self::new(session, stream, publish.into(), subscribe.into()).await?; Ok(session) } - async fn connect_setup(setup: &mut Stream) -> Result<(), Error> { + async fn connect_setup(setup: &mut Stream) -> Result<(), Error> { let client = message::ClientSetup { versions: [message::Version::CURRENT].into(), extensions: Default::default(), @@ -99,17 +100,12 @@ impl Session { } /// Perform the MoQ handshake as a server - pub async fn accept< - T: Into, - P: Into>, - C: Into>, - >( - session: T, + pub async fn accept>, C: Into>>( + session: S, publish: P, subscribe: C, ) -> Result { - let mut session = session.into(); - let mut stream = Stream::accept(&mut session).await?; + let mut stream = Stream::accept(&session).await?; let kind = stream.reader.decode().await?; Self::accept_setup(kind, &mut stream).await?; @@ -117,13 +113,12 @@ impl Session { Ok(session) } - async fn accept_setup(kind: message::ControlType, control: &mut Stream) -> Result<(), Error> { + async fn accept_setup(kind: message::ControlType, control: &mut Stream) -> Result<(), Error> { if kind != message::ControlType::Session && kind != message::ControlType::ClientCompat { return Err(Error::UnexpectedStream(kind)); } let client: message::ClientSetup = control.reader.decode().await?; - if !client.versions.contains(&message::Version::CURRENT) { return Err(Error::Version(client.versions, [message::Version::CURRENT].into())); } @@ -147,18 +142,18 @@ impl Session { } // TODO do something useful with this - async fn run_session(mut stream: Stream) -> Result<(), Error> { + async fn run_session(mut stream: Stream) -> Result<(), Error> { while let Some(_info) = stream.reader.decode_maybe::().await? {} Err(Error::Cancel) } - /// Close the underlying WebTransport session. - pub fn close(mut self, err: Error) { - self.webtransport.close(err.to_code(), &err.to_string()); + /// Close the underlying transport session. + pub fn close(self, err: Error) { + self.transport.close(err.to_code(), err.to_string().as_ref()); } - /// Block until the WebTransport session is closed. + /// Block until the transport session is closed. pub async fn closed(&self) -> Error { - self.webtransport.closed().await.into() + Error::Transport(Arc::new(self.transport.closed().await)) } } diff --git a/rs/moq/src/session/publisher.rs b/rs/moq/src/session/publisher.rs index 6181471cf..630a78f0f 100644 --- a/rs/moq/src/session/publisher.rs +++ b/rs/moq/src/session/publisher.rs @@ -1,4 +1,7 @@ +use std::sync::Arc; + use web_async::FuturesExt; +use web_transport_trait::SendStream; use crate::{ message, model::GroupConsumer, AsPath, BroadcastConsumer, Error, Origin, OriginConsumer, Track, TrackConsumer, @@ -6,13 +9,13 @@ use crate::{ use super::{Stream, Writer}; -pub(super) struct Publisher { - session: web_transport::Session, +pub(super) struct Publisher { + session: S, origin: OriginConsumer, } -impl Publisher { - pub fn new(session: web_transport::Session, origin: Option) -> Self { +impl Publisher { + pub fn new(session: S, origin: Option) -> Self { // Default to a dummy origin that is immediately closed. let origin = origin.unwrap_or_else(|| Origin::produce().consumer); Self { session, origin } @@ -25,7 +28,7 @@ impl Publisher { async fn run_bi(mut self) -> Result<(), Error> { loop { - let mut stream = Stream::accept(&mut self.session).await?; + let mut stream = Stream::accept(&self.session).await?; // To avoid cloning the origin, we process each control stream in received order. // This adds some head-of-line blocking but it delays an expensive clone. @@ -43,7 +46,7 @@ impl Publisher { } } - pub async fn recv_announce(&mut self, mut stream: Stream) -> Result<(), Error> { + pub async fn recv_announce(&mut self, mut stream: Stream) -> Result<(), Error> { let interest = stream.reader.decode::().await?; let prefix = interest.prefix.to_owned(); @@ -61,7 +64,7 @@ impl Publisher { Error::Cancel => { tracing::debug!(prefix = %origin.absolute(prefix), "announcing cancelled"); } - Error::WebTransport(_) => { + Error::Transport(_) => { tracing::debug!(prefix = %origin.absolute(prefix), "announcing cancelled"); } err => { @@ -78,7 +81,11 @@ impl Publisher { Ok(()) } - async fn run_announce(stream: &mut Stream, origin: &mut OriginConsumer, prefix: impl AsPath) -> Result<(), Error> { + async fn run_announce( + stream: &mut Stream, + origin: &mut OriginConsumer, + prefix: impl AsPath, + ) -> Result<(), Error> { let prefix = prefix.as_path(); let mut init = Vec::new(); @@ -120,14 +127,14 @@ impl Publisher { stream.writer.encode(&msg).await?; } }, - None => return stream.writer.close().await, + None => return stream.writer.finish().await, } } } } } - pub async fn recv_subscribe(&mut self, mut stream: Stream) -> Result<(), Error> { + pub async fn recv_subscribe(&mut self, mut stream: Stream) -> Result<(), Error> { let subscribe = stream.reader.decode::().await?; let id = subscribe.id; @@ -146,7 +153,7 @@ impl Publisher { tracing::debug!(%id, broadcast = %absolute, %track, "subscribed cancelled") } // TODO better classify WebTransport errors. - Error::WebTransport(_) => { + Error::Transport(_) => { tracing::debug!(%id, broadcast = %absolute, %track, "subscribed cancelled") } err => { @@ -163,8 +170,8 @@ impl Publisher { } async fn run_subscribe( - session: web_transport::Session, - stream: &mut Stream, + session: S, + stream: &mut Stream, subscribe: &message::Subscribe<'_>, consumer: Option, ) -> Result<(), Error> { @@ -189,14 +196,10 @@ impl Publisher { res = stream.reader.closed() => res?, } - stream.writer.close().await + stream.writer.finish().await } - async fn run_track( - session: web_transport::Session, - mut track: TrackConsumer, - subscribe: &message::Subscribe<'_>, - ) -> Result<(), Error> { + async fn run_track(session: S, mut track: TrackConsumer, subscribe: &message::Subscribe<'_>) -> Result<(), Error> { // TODO use a BTreeMap serve the latest N groups by sequence. // Until then, we'll implement N=2 manually. // Also, this is more complicated because we can't use tokio because of WASM. @@ -241,7 +244,7 @@ impl Publisher { continue; } - let priority = Self::stream_priority(track.info.priority, sequence); + let priority = stream_priority(track.info.priority, sequence); let msg = message::Group { subscribe: subscribe.id, sequence, @@ -257,6 +260,8 @@ impl Publisher { old_group.take(); // Drop the future to cancel it. } + assert!(old_group.is_none()); + if sequence >= *latest { old_group = new_group; old_sequence = new_sequence; @@ -271,14 +276,20 @@ impl Publisher { } pub async fn serve_group( - mut session: web_transport::Session, + session: S, msg: message::Group, priority: i32, mut group: GroupConsumer, ) -> Result<(), Error> { // TODO add a way to open in priority order. - let mut stream = Writer::open(&mut session, message::DataType::Group).await?; + let mut stream = session + .open_uni() + .await + .map_err(|err| Error::Transport(Arc::new(err)))?; stream.set_priority(priority); + + let mut stream = Writer::new(stream); + stream.encode(&message::DataType::Group).await?; stream.encode(&msg).await?; loop { @@ -293,6 +304,8 @@ impl Publisher { None => break, }; + tracing::trace!(size = %frame.info.size, "writing frame"); + stream.encode(&frame.info.size).await?; loop { @@ -303,26 +316,30 @@ impl Publisher { }; match chunk? { - Some(chunk) => stream.write(&chunk).await?, + Some(mut chunk) => stream.write_all(&mut chunk).await?, None => break, } } + + tracing::trace!(size = %frame.info.size, "wrote frame"); } - stream.close().await?; + stream.finish().await?; + + tracing::debug!(sequence = %msg.sequence, "finished group"); Ok(()) } +} - // Quinn takes a i32 priority. - // We do our best to distill 70 bits of information into 32 bits, but overflows will happen. - // Specifically, group sequence 2^24 will overflow and be incorrectly prioritized. - // But even with a group per frame, it will take ~6 days to reach that point. - // TODO The behavior when two tracks share the same priority is undefined. Should we round-robin? - fn stream_priority(track_priority: u8, group_sequence: u64) -> i32 { - let sequence = 0xFFFFFF - (group_sequence as u32 & 0xFFFFFF); - ((track_priority as i32) << 24) | sequence as i32 - } +// Quinn takes a i32 priority. +// We do our best to distill 70 bits of information into 32 bits, but overflows will happen. +// Specifically, group sequence 2^24 will overflow and be incorrectly prioritized. +// But even with a group per frame, it will take ~6 days to reach that point. +// TODO The behavior when two tracks share the same priority is undefined. Should we round-robin? +fn stream_priority(track_priority: u8, group_sequence: u64) -> i32 { + let sequence = 0xFFFFFF - (group_sequence as u32 & 0xFFFFFF); + ((track_priority as i32) << 24) | sequence as i32 } #[cfg(test)] @@ -330,9 +347,9 @@ mod test { use super::*; #[test] - fn stream_priority() { + fn priority() { let assert = |track_priority, group_sequence, expected| { - assert_eq!(Publisher::stream_priority(track_priority, group_sequence), expected); + assert_eq!(stream_priority(track_priority, group_sequence), expected); }; const U24: i32 = (1 << 24) - 1; diff --git a/rs/moq/src/session/reader.rs b/rs/moq/src/session/reader.rs index cbd7267a6..759be3d4c 100644 --- a/rs/moq/src/session/reader.rs +++ b/rs/moq/src/session/reader.rs @@ -1,27 +1,22 @@ -use std::{cmp, io}; +use std::{cmp, io, sync::Arc}; use bytes::{Buf, Bytes, BytesMut}; use crate::{coding::*, Error}; -pub struct Reader { - stream: web_transport::RecvStream, +pub struct Reader { + stream: S, buffer: BytesMut, } -impl Reader { - pub fn new(stream: web_transport::RecvStream) -> Self { +impl Reader { + pub fn new(stream: S) -> Self { Self { stream, buffer: Default::default(), } } - pub async fn accept(session: &mut web_transport::Session) -> Result { - let stream = session.accept_uni().await?; - Ok(Self::new(stream)) - } - pub async fn decode(&mut self) -> Result { loop { let mut cursor = io::Cursor::new(&self.buffer); @@ -32,7 +27,13 @@ impl Reader { } Err(DecodeError::Short) => { // Try to read more data - if self.stream.read_buf(&mut self.buffer).await?.is_none() { + if self + .stream + .read_buf(&mut self.buffer) + .await + .map_err(|e| Error::Transport(Arc::new(e)))? + .is_none() + { // Stream closed while we still need more data return Err(Error::Decode(DecodeError::Short)); } @@ -59,12 +60,22 @@ impl Reader { return Ok(Some(data)); } - Ok(self.stream.read(max).await?) + self.stream + .read_chunk(max) + .await + .map_err(|e| Error::Transport(Arc::new(e))) } /// Wait until the stream is closed, erroring if there are any additional bytes. pub async fn closed(&mut self) -> Result<(), Error> { - if self.buffer.is_empty() && self.stream.read_buf(&mut self.buffer).await?.is_none() { + if self.buffer.is_empty() + && self + .stream + .read_buf(&mut self.buffer) + .await + .map_err(|e| Error::Transport(Arc::new(e)))? + .is_none() + { return Ok(()); } diff --git a/rs/moq/src/session/stream.rs b/rs/moq/src/session/stream.rs index 4b2b5fe40..4d9b1d1eb 100644 --- a/rs/moq/src/session/stream.rs +++ b/rs/moq/src/session/stream.rs @@ -1,14 +1,16 @@ +use std::sync::Arc; + use super::{Reader, Writer}; use crate::{message, Error}; -pub(super) struct Stream { - pub writer: Writer, - pub reader: Reader, +pub(super) struct Stream { + pub writer: Writer, + pub reader: Reader, } -impl Stream { - pub async fn open(session: &mut web_transport::Session, typ: message::ControlType) -> Result { - let (send, recv) = session.open_bi().await?; +impl Stream { + pub async fn open(session: &S, typ: message::ControlType) -> Result { + let (send, recv) = session.open_bi().await.map_err(|err| Error::Transport(Arc::new(err)))?; let mut writer = Writer::new(send); let reader = Reader::new(recv); @@ -17,8 +19,11 @@ impl Stream { Ok(Stream { writer, reader }) } - pub async fn accept(session: &mut web_transport::Session) -> Result { - let (send, recv) = session.accept_bi().await?; + pub async fn accept(session: &S) -> Result { + let (send, recv) = session + .accept_bi() + .await + .map_err(|err| Error::Transport(Arc::new(err)))?; let writer = Writer::new(send); let reader = Reader::new(recv); diff --git a/rs/moq/src/session/subscriber.rs b/rs/moq/src/session/subscriber.rs index 2fd97d4c8..92a55811d 100644 --- a/rs/moq/src/session/subscriber.rs +++ b/rs/moq/src/session/subscriber.rs @@ -9,26 +9,24 @@ use crate::{ }; use tokio::sync::oneshot; -use web_async::{spawn, Lock}; +use web_async::Lock; use super::{Reader, Stream}; #[derive(Clone)] -pub(super) struct Subscriber { - session: web_transport::Session, +pub(super) struct Subscriber { + session: S, origin: Option, - broadcasts: Lock>, subscribes: Lock>, next_id: Arc, } -impl Subscriber { - pub fn new(session: web_transport::Session, origin: Option) -> Self { +impl Subscriber { + pub fn new(session: S, origin: Option) -> Self { Self { session, origin, - broadcasts: Default::default(), subscribes: Default::default(), next_id: Default::default(), } @@ -42,18 +40,26 @@ impl Subscriber { } } - async fn run_uni(mut self) -> Result<(), Error> { + async fn run_uni(self) -> Result<(), Error> { loop { - let stream = Reader::accept(&mut self.session).await?; + let stream = self + .session + .accept_uni() + .await + .map_err(|err| Error::Transport(Arc::new(err)))?; + + let stream = Reader::new(stream); let this = self.clone(); web_async::spawn(async move { - this.run_uni_stream(stream).await.ok(); + if let Err(err) = this.run_uni_stream(stream).await { + tracing::debug!(%err, "error running uni stream"); + } }); } } - async fn run_uni_stream(mut self, mut stream: Reader) -> Result<(), Error> { + async fn run_uni_stream(mut self, mut stream: Reader) -> Result<(), Error> { let kind = stream.decode().await?; let res = match kind { @@ -74,7 +80,7 @@ impl Subscriber { return Ok(()); } - let mut stream = Stream::open(&mut self.session, message::ControlType::Announce).await?; + let mut stream = Stream::open(&self.session, message::ControlType::Announce).await?; tracing::trace!(root = %self.log_path(""), "announced start"); @@ -108,7 +114,7 @@ impl Subscriber { } // Close the stream when there's nothing more to announce. - stream.writer.close().await + stream.writer.finish().await } fn start_announce( @@ -132,7 +138,7 @@ impl Subscriber { .unwrap() .publish_broadcast(path.clone(), broadcast.consumer); - spawn(self.clone().run_broadcast(path, broadcast.producer)); + web_async::spawn(self.clone().run_broadcast(path, broadcast.producer)); Ok(()) } @@ -155,14 +161,11 @@ impl Subscriber { let mut this = self.clone(); let path = path.clone(); - spawn(async move { + web_async::spawn(async move { this.run_subscribe(id, path, track).await; this.subscribes.lock().remove(&id); }); } - - // Remove the broadcast from the lookup. - self.broadcasts.lock().remove(&path); } async fn run_subscribe(&mut self, id: u64, broadcast: Path<'_>, track: TrackProducer) { @@ -183,7 +186,7 @@ impl Subscriber { }; match res { - Err(Error::Cancel) | Err(Error::WebTransport(_)) => { + Err(Error::Cancel) | Err(Error::Transport(_)) => { tracing::debug!(broadcast = %self.log_path(&broadcast), track = %track.info.name, id, "subscribe cancelled"); track.abort(Error::Cancel); } @@ -199,17 +202,17 @@ impl Subscriber { } async fn run_track(&mut self, msg: message::Subscribe<'_>) -> Result<(), Error> { - let mut stream = Stream::open(&mut self.session, message::ControlType::Subscribe).await?; + let mut stream = Stream::open(&self.session, message::ControlType::Subscribe).await?; if let Err(err) = self.run_track_stream(&mut stream, msg).await { stream.writer.abort(&err); return Err(err); } - stream.writer.close().await + stream.writer.finish().await } - async fn run_track_stream(&mut self, stream: &mut Stream, msg: message::Subscribe<'_>) -> Result<(), Error> { + async fn run_track_stream(&mut self, stream: &mut Stream, msg: message::Subscribe<'_>) -> Result<(), Error> { stream.writer.encode(&msg).await?; // TODO use the response correctly populate the track info @@ -221,7 +224,7 @@ impl Subscriber { Ok(()) } - pub async fn recv_group(&mut self, stream: &mut Reader) -> Result<(), Error> { + pub async fn recv_group(&mut self, stream: &mut Reader) -> Result<(), Error> { let group: message::Group = stream.decode().await?; let group = { @@ -240,7 +243,7 @@ impl Subscriber { }; match res { - Err(Error::Cancel) | Err(Error::WebTransport(_)) => { + Err(Error::Cancel) | Err(Error::Transport(_)) => { tracing::trace!(group = %group.info.sequence, "group cancelled"); group.abort(Error::Cancel); } @@ -257,7 +260,7 @@ impl Subscriber { Ok(()) } - async fn run_group(&mut self, stream: &mut Reader, mut group: GroupProducer) -> Result<(), Error> { + async fn run_group(&mut self, stream: &mut Reader, mut group: GroupProducer) -> Result<(), Error> { while let Some(size) = stream.decode_maybe::().await? { let frame = group.create_frame(Frame { size }); @@ -277,15 +280,19 @@ impl Subscriber { Ok(()) } - async fn run_frame(&mut self, stream: &mut Reader, mut frame: FrameProducer) -> Result<(), Error> { + async fn run_frame(&mut self, stream: &mut Reader, mut frame: FrameProducer) -> Result<(), Error> { let mut remain = frame.info.size; + tracing::trace!(size = %frame.info.size, "reading frame"); + while remain > 0 { let chunk = stream.read(remain as usize).await?.ok_or(Error::WrongSize)?; remain = remain.checked_sub(chunk.len() as u64).ok_or(Error::WrongSize)?; frame.write_chunk(chunk); } + tracing::trace!(size = %frame.info.size, "read frame"); + frame.close(); Ok(()) diff --git a/rs/moq/src/session/writer.rs b/rs/moq/src/session/writer.rs index a954bdd63..36a10462e 100644 --- a/rs/moq/src/session/writer.rs +++ b/rs/moq/src/session/writer.rs @@ -1,52 +1,54 @@ -use crate::{coding::*, message, Error}; +use std::sync::Arc; -// A wrapper around a web_transport::SendStream that will reset on Drop -pub(super) struct Writer { - stream: web_transport::SendStream, +use crate::{coding::*, Error}; + +// A wrapper around a SendStream that will reset on Drop +pub(super) struct Writer { + stream: S, buffer: bytes::BytesMut, } -impl Writer { - pub fn new(stream: web_transport::SendStream) -> Self { +impl Writer { + pub fn new(stream: S) -> Self { Self { stream, buffer: Default::default(), } } - pub async fn open(session: &mut web_transport::Session, typ: message::DataType) -> Result { - let send = session.open_uni().await?; - - let mut writer = Self::new(send); - writer.encode(&typ).await?; - - Ok(writer) - } - pub async fn encode(&mut self, msg: &T) -> Result<(), Error> { self.buffer.clear(); msg.encode(&mut self.buffer); while !self.buffer.is_empty() { - self.stream.write_buf(&mut self.buffer).await?; + self.stream + .write_buf(&mut self.buffer) + .await + .map_err(|e| Error::Transport(Arc::new(e)))?; } Ok(()) } - pub async fn write(&mut self, buf: &[u8]) -> Result<(), Error> { - self.stream.write(buf).await?; // convert the error type - Ok(()) + // Not public to avoid accidental partial writes. + async fn write(&mut self, buf: &mut Buf) -> Result { + self.stream + .write_buf(buf) + .await + .map_err(|e| Error::Transport(Arc::new(e))) } - pub fn set_priority(&mut self, priority: i32) { - self.stream.set_priority(priority); + // NOTE: We use Buf so we don't perform a copy when using Quinn. + pub async fn write_all(&mut self, buf: &mut Buf) -> Result<(), Error> { + while buf.has_remaining() { + self.write(buf).await?; + } + Ok(()) } /// A clean termination of the stream, waiting for the peer to close. - pub async fn close(&mut self) -> Result<(), Error> { - self.stream.finish()?; - self.stream.closed().await?; // TODO Return any error code? + pub async fn finish(&mut self) -> Result<(), Error> { + self.stream.finish().await.map_err(|e| Error::Transport(Arc::new(e)))?; Ok(()) } @@ -55,12 +57,12 @@ impl Writer { } pub async fn closed(&mut self) -> Result<(), Error> { - self.stream.closed().await?; + self.stream.closed().await.map_err(|e| Error::Transport(Arc::new(e)))?; Ok(()) } } -impl Drop for Writer { +impl Drop for Writer { fn drop(&mut self) { // Unlike the Quinn default, we abort the stream on drop. self.stream.reset(Error::Cancel.to_code());