diff --git a/.claude/hooks/direnv.sh b/.claude/hooks/direnv.sh
new file mode 100755
index 0000000000..5826f4a24f
--- /dev/null
+++ b/.claude/hooks/direnv.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# Load the project's direnv/nix dev shell into the Claude Code session so Bash
+# tool commands resolve flake-pinned tools (bun, nixfmt, ...) instead of system
+# ones. No-op for anyone without direnv or an .envrc, so non-nix setups are
+# unaffected.
+#
+# direnv only *approves* the .envrc; the actual env is exported into
+# $CLAUDE_ENV_FILE, which Claude Code sources for every later Bash command.
+
+set -u
+
+# Nothing to export into without this; older Claude Code versions won't set it.
+[ -n "${CLAUDE_ENV_FILE:-}" ] || exit 0
+
+command -v direnv >/dev/null 2>&1 || exit 0
+
+# Require an explicit project dir so we never approve/export a stray .envrc from
+# some unrelated working directory.
+[ -n "${CLAUDE_PROJECT_DIR:-}" ] || exit 0
+cd "$CLAUDE_PROJECT_DIR" || exit 0
+[ -f .envrc ] || exit 0
+
+# Clear any inherited direnv state so `export` recomputes the full diff. Without
+# this, a stale DIRENV_DIFF from the parent process makes direnv assume the env
+# is already loaded and emit nothing.
+unset DIRENV_DIR DIRENV_DIFF DIRENV_WATCHES DIRENV_FILE DIRENV_LAYOUT
+
+direnv allow . 2>/dev/null
+direnv export bash >>"$CLAUDE_ENV_FILE" 2>/dev/null
+
+exit 0
diff --git a/.claude/settings.json b/.claude/settings.json
index 8c4e5f038d..954f580759 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,23 +1,30 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
+ "hooks": {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/direnv.sh"
+ }
+ ]
+ }
+ ]
+ },
"permissions": {
"allow": [
- "Bash(ls:*)",
- "Bash(grep:*)",
- "Bash(find:*)",
- "Bash(git status:*)",
- "Bash(git diff:*)",
- "Bash(git log:*)",
- "Bash(git add:*)",
- "Bash(git commit:*)",
- "Bash(git push:*)",
- "Bash(git pull:*)",
- "Bash(git checkout:*)",
- "Bash(git branch:*)",
"Bash(just *)",
"Bash(cargo *)",
"Bash(bun *)",
- "WebSearch"
+ "Bash(ffprobe:*)",
+ "Bash(rs/libmoq/build.sh:*)",
+ "WebFetch(domain:www.ietf.org)",
+ "WebFetch(domain:datatracker.ietf.org)",
+ "WebFetch(domain:docs.rs)",
+ "WebFetch(domain:moq-wg.github.io)",
+ "WebFetch(domain:moq.dev)",
+ "WebFetch(domain:doc.moq.dev)"
]
}
}
diff --git a/.editorconfig b/.editorconfig
index 82a15b8e86..f982778b08 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -23,3 +23,19 @@ indent_size = 2
[**.nix]
indent_style = space
indent_size = 2
+
+# shfmt reads these natively. Match existing script style to keep
+# the format pass minimal.
+[*.sh]
+indent_style = space
+indent_size = 4
+switch_case_indent = true
+
+[demo/throttle/enable]
+indent_style = space
+indent_size = 4
+switch_case_indent = true
+
+# Skip vendored / build output trees when shfmt walks the repo.
+[{node_modules,target,dist,.venv}/**]
+ignore = true
diff --git a/.env b/.env
new file mode 100644
index 0000000000..99feaf1a31
--- /dev/null
+++ b/.env
@@ -0,0 +1,2 @@
+RUST_BACKTRACE=1
+RUST_LOG="debug"
diff --git a/.envrc b/.envrc
index b06bc5bb61..76439bd451 100644
--- a/.envrc
+++ b/.envrc
@@ -10,3 +10,13 @@ if ! has nix_direnv_version || ! nix_direnv_version 3.1.0; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.0/direnvrc" "sha256-yMJ2OVMzrFaDPn7q8nCBZFRYpL/f0RcHzhmw/i6btJM="
fi
use_flake
+
+# Reclaim stale Nix store paths in the background, at most once per week.
+# nix-direnv pins the current dev shell, so only older versions are freed.
+if has nix-collect-garbage; then
+ _gc_stamp="$PWD/.direnv/nix-gc-stamp"
+ if [ ! -f "$_gc_stamp" ] || [ -n "$(find "$_gc_stamp" -mtime +7 2>/dev/null)" ]; then
+ touch "$_gc_stamp"
+ (nix-collect-garbage --delete-older-than 7d >/dev/null 2>&1 &)
+ fi
+fi
diff --git a/.github/actions/setup-packaging/action.yml b/.github/actions/setup-packaging/action.yml
new file mode 100644
index 0000000000..c3e26e4c3c
--- /dev/null
+++ b/.github/actions/setup-packaging/action.yml
@@ -0,0 +1,16 @@
+name: Set up packaging tools
+description: Install Nix and prepend the .#packaging output bin/ to $PATH.
+
+runs:
+ using: composite
+ steps:
+ - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main
+ with:
+ determinate: false
+ - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main
+
+ - name: Build packaging tools from flake
+ shell: bash
+ run: |
+ out=$(nix build --no-link --print-out-paths .#packaging)
+ echo "$out/bin" >> "$GITHUB_PATH"
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 6867e71eb1..6cd87d7879 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -9,3 +9,5 @@ updates:
directory: "/"
schedule:
interval: "weekly"
+ cooldown:
+ default-days: 7
diff --git a/.github/homebrew/Formula/moq-cli.rb.tmpl b/.github/homebrew/Formula/moq-cli.rb.tmpl
new file mode 100644
index 0000000000..2f92dcf94f
--- /dev/null
+++ b/.github/homebrew/Formula/moq-cli.rb.tmpl
@@ -0,0 +1,36 @@
+class MoqCli < Formula
+ desc "CLI for publishing and subscribing to Media over QUIC broadcasts"
+ homepage "https://moq.dev"
+ version "__VERSION__"
+ license any_of: ["MIT", "Apache-2.0"]
+
+ on_macos do
+ on_arm do
+ url "https://github.com/moq-dev/moq/releases/download/moq-cli-v#{version}/moq-cli-#{version}-aarch64-apple-darwin.tar.gz"
+ sha256 "__SHA256_AARCH64_APPLE_DARWIN__"
+ end
+ on_intel do
+ url "https://github.com/moq-dev/moq/releases/download/moq-cli-v#{version}/moq-cli-#{version}-x86_64-apple-darwin.tar.gz"
+ sha256 "__SHA256_X86_64_APPLE_DARWIN__"
+ end
+ end
+
+ on_linux do
+ on_arm do
+ url "https://github.com/moq-dev/moq/releases/download/moq-cli-v#{version}/moq-cli-#{version}-aarch64-unknown-linux-gnu.tar.gz"
+ sha256 "__SHA256_AARCH64_UNKNOWN_LINUX_GNU__"
+ end
+ on_intel do
+ url "https://github.com/moq-dev/moq/releases/download/moq-cli-v#{version}/moq-cli-#{version}-x86_64-unknown-linux-gnu.tar.gz"
+ sha256 "__SHA256_X86_64_UNKNOWN_LINUX_GNU__"
+ end
+ end
+
+ def install
+ bin.install "bin/moq-cli"
+ end
+
+ test do
+ system bin/"moq-cli", "--help"
+ end
+end
diff --git a/.github/homebrew/Formula/moq-gst.rb.tmpl b/.github/homebrew/Formula/moq-gst.rb.tmpl
new file mode 100644
index 0000000000..56c2c7c580
--- /dev/null
+++ b/.github/homebrew/Formula/moq-gst.rb.tmpl
@@ -0,0 +1,54 @@
+class MoqGst < Formula
+ desc "GStreamer plugin for publishing and subscribing to Media over QUIC"
+ homepage "https://moq.dev"
+ version "__VERSION__"
+ license any_of: ["MIT", "Apache-2.0"]
+
+ depends_on "gstreamer"
+
+ on_macos do
+ on_arm do
+ url "https://github.com/moq-dev/moq/releases/download/moq-gst-v#{version}/moq-gst-#{version}-aarch64-apple-darwin.tar.gz"
+ sha256 "__SHA256_AARCH64_APPLE_DARWIN__"
+ end
+ on_intel do
+ url "https://github.com/moq-dev/moq/releases/download/moq-gst-v#{version}/moq-gst-#{version}-x86_64-apple-darwin.tar.gz"
+ sha256 "__SHA256_X86_64_APPLE_DARWIN__"
+ end
+ end
+
+ on_linux do
+ on_arm do
+ url "https://github.com/moq-dev/moq/releases/download/moq-gst-v#{version}/moq-gst-#{version}-aarch64-unknown-linux-gnu.tar.gz"
+ sha256 "__SHA256_AARCH64_UNKNOWN_LINUX_GNU__"
+ end
+ on_intel do
+ url "https://github.com/moq-dev/moq/releases/download/moq-gst-v#{version}/moq-gst-#{version}-x86_64-unknown-linux-gnu.tar.gz"
+ sha256 "__SHA256_X86_64_UNKNOWN_LINUX_GNU__"
+ end
+ end
+
+ def install
+ lib.install Dir["lib/libgstmoq.*"]
+ end
+
+ def caveats
+ <<~EOS
+ The moq GStreamer plugin is installed at:
+ #{opt_lib}/libgstmoq.#{OS.mac? ? "dylib" : "so"}
+
+ For GStreamer to discover it, add this prefix to GST_PLUGIN_PATH:
+ export GST_PLUGIN_PATH="#{opt_lib}:$GST_PLUGIN_PATH"
+
+ Then verify with:
+ gst-inspect-1.0 moq
+ EOS
+ end
+
+ test do
+ plugin = OS.mac? ? "libgstmoq.dylib" : "libgstmoq.so"
+ assert_predicate lib/plugin, :exist?
+ ENV["GST_PLUGIN_PATH"] = lib
+ system Formula["gstreamer"].opt_bin/"gst-inspect-1.0", "moq"
+ end
+end
diff --git a/.github/homebrew/Formula/moq-relay.rb.tmpl b/.github/homebrew/Formula/moq-relay.rb.tmpl
new file mode 100644
index 0000000000..808c971cec
--- /dev/null
+++ b/.github/homebrew/Formula/moq-relay.rb.tmpl
@@ -0,0 +1,36 @@
+class MoqRelay < Formula
+ desc "Clusterable relay server for Media over QUIC"
+ homepage "https://moq.dev"
+ version "__VERSION__"
+ license any_of: ["MIT", "Apache-2.0"]
+
+ on_macos do
+ on_arm do
+ url "https://github.com/moq-dev/moq/releases/download/moq-relay-v#{version}/moq-relay-#{version}-aarch64-apple-darwin.tar.gz"
+ sha256 "__SHA256_AARCH64_APPLE_DARWIN__"
+ end
+ on_intel do
+ url "https://github.com/moq-dev/moq/releases/download/moq-relay-v#{version}/moq-relay-#{version}-x86_64-apple-darwin.tar.gz"
+ sha256 "__SHA256_X86_64_APPLE_DARWIN__"
+ end
+ end
+
+ on_linux do
+ on_arm do
+ url "https://github.com/moq-dev/moq/releases/download/moq-relay-v#{version}/moq-relay-#{version}-aarch64-unknown-linux-gnu.tar.gz"
+ sha256 "__SHA256_AARCH64_UNKNOWN_LINUX_GNU__"
+ end
+ on_intel do
+ url "https://github.com/moq-dev/moq/releases/download/moq-relay-v#{version}/moq-relay-#{version}-x86_64-unknown-linux-gnu.tar.gz"
+ sha256 "__SHA256_X86_64_UNKNOWN_LINUX_GNU__"
+ end
+ end
+
+ def install
+ bin.install "bin/moq-relay"
+ end
+
+ test do
+ system bin/"moq-relay", "--help"
+ end
+end
diff --git a/.github/homebrew/Formula/moq-token-cli.rb.tmpl b/.github/homebrew/Formula/moq-token-cli.rb.tmpl
new file mode 100644
index 0000000000..96689723ec
--- /dev/null
+++ b/.github/homebrew/Formula/moq-token-cli.rb.tmpl
@@ -0,0 +1,36 @@
+class MoqTokenCli < Formula
+ desc "JWT token generator and validator for moq-relay"
+ homepage "https://moq.dev"
+ version "__VERSION__"
+ license any_of: ["MIT", "Apache-2.0"]
+
+ on_macos do
+ on_arm do
+ url "https://github.com/moq-dev/moq/releases/download/moq-token-cli-v#{version}/moq-token-cli-#{version}-aarch64-apple-darwin.tar.gz"
+ sha256 "__SHA256_AARCH64_APPLE_DARWIN__"
+ end
+ on_intel do
+ url "https://github.com/moq-dev/moq/releases/download/moq-token-cli-v#{version}/moq-token-cli-#{version}-x86_64-apple-darwin.tar.gz"
+ sha256 "__SHA256_X86_64_APPLE_DARWIN__"
+ end
+ end
+
+ on_linux do
+ on_arm do
+ url "https://github.com/moq-dev/moq/releases/download/moq-token-cli-v#{version}/moq-token-cli-#{version}-aarch64-unknown-linux-gnu.tar.gz"
+ sha256 "__SHA256_AARCH64_UNKNOWN_LINUX_GNU__"
+ end
+ on_intel do
+ url "https://github.com/moq-dev/moq/releases/download/moq-token-cli-v#{version}/moq-token-cli-#{version}-x86_64-unknown-linux-gnu.tar.gz"
+ sha256 "__SHA256_X86_64_UNKNOWN_LINUX_GNU__"
+ end
+ end
+
+ def install
+ bin.install "bin/moq-token-cli"
+ end
+
+ test do
+ system bin/"moq-token-cli", "--help"
+ end
+end
diff --git a/.github/justfile b/.github/justfile
new file mode 100644
index 0000000000..137021cfcf
--- /dev/null
+++ b/.github/justfile
@@ -0,0 +1,9 @@
+set fallback
+
+# Lint GitHub Actions workflows. Silently skipped if actionlint is not installed.
+check:
+ @if command -v actionlint >/dev/null 2>&1; then actionlint; fi
+
+# CI variant: actionlint is required (provided by the nix devShell).
+ci:
+ actionlint
diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh
new file mode 100755
index 0000000000..67cfb5cfef
--- /dev/null
+++ b/.github/scripts/release.sh
@@ -0,0 +1,143 @@
+#!/usr/bin/env bash
+#
+# Shared release helpers for GitHub Actions workflows.
+# Usage:
+# release.sh parse-version — extract SemVer from GITHUB_REF given a tag prefix
+# release.sh prev-tag — find the tag immediately before the current one
+# release.sh create — create or update a GitHub release with artifacts
+# release.sh read-version — read `version = "x.y.z"` from a manifest
+# release.sh pypi-exists — check whether == is already on PyPI
+#
+# Environment:
+# GITHUB_REF — set by GitHub Actions (e.g. refs/tags/moq-relay-v1.2.3)
+# GITHUB_OUTPUT — set by GitHub Actions (for writing step outputs)
+# GH_TOKEN — required for `create` subcommand
+
+set -euo pipefail
+
+# Parse a SemVer version from GITHUB_REF given a tag prefix.
+# Writes version= to $GITHUB_OUTPUT.
+parse_version() {
+ local prefix="$1"
+ local ref="${GITHUB_REF#refs/tags/}"
+
+ if [[ "$ref" =~ ^${prefix}-v([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?)$ ]]; then
+ local version="${BASH_REMATCH[1]}"
+ echo "version=${version}" >>"$GITHUB_OUTPUT"
+ echo "Parsed version: ${version}"
+ else
+ echo "Tag format not recognized: $ref (expected ${prefix}-v)" >&2
+ exit 1
+ fi
+}
+
+# Find the tag immediately before the current one (by version sort order).
+# Writes tag= to $GITHUB_OUTPUT.
+prev_tag() {
+ local prefix="$1"
+ local current_tag="${GITHUB_REF#refs/tags/}"
+
+ local prev
+ prev=$(git tag --list "${prefix}-v*" --sort=v:refname |
+ awk -v cur="$current_tag" '$0 == cur { print prev; found=1; exit } { prev=$0 } END { if (!found) print "" }')
+
+ echo "tag=${prev}" >>"$GITHUB_OUTPUT"
+ echo "Previous tag: ${prev:-none}"
+}
+
+# Create or update a GitHub release with artifacts.
+# Args:
+# Reads tag/title/prev_tag from environment or step outputs.
+create_release() {
+ local artifacts_dir="$1"
+ local tag="${RELEASE_TAG:?RELEASE_TAG must be set}"
+ local title="${RELEASE_TITLE:?RELEASE_TITLE must be set}"
+ local prev_tag="${RELEASE_PREV_TAG:-}"
+
+ if gh release view "$tag" >/dev/null 2>&1; then
+ echo "Release exists, updating assets and metadata..."
+ gh release upload "$tag" "$artifacts_dir"/* --clobber
+ if [ -n "$prev_tag" ]; then
+ gh release edit "$tag" --title "$title" --notes-start-tag "$prev_tag"
+ else
+ gh release edit "$tag" --title "$title"
+ fi
+ else
+ echo "Creating new release..."
+ if [ -n "$prev_tag" ]; then
+ gh release create "$tag" \
+ --title "$title" \
+ --generate-notes \
+ --notes-start-tag "$prev_tag" \
+ "$artifacts_dir"/*
+ else
+ gh release create "$tag" \
+ --title "$title" \
+ --generate-notes \
+ "$artifacts_dir"/*
+ fi
+ fi
+}
+
+# Read the static `version = "x.y.z"` from the [project] table of a
+# pyproject.toml. Scoped to [project] so a `version` key in another table
+# (e.g. a [tool.*] section) can't be picked up by mistake. Writes
+# version= to $GITHUB_OUTPUT and stdout.
+read_version() {
+ local manifest="$1"
+ local version
+ version=$(sed -n '/^\[project\]/,/^\[/{
+ s/^[[:space:]]*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p
+ }' "$manifest" | head -n1)
+ if [[ -z "$version" ]]; then
+ echo "Could not read version from [project] in $manifest" >&2
+ exit 1
+ fi
+ if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
+ echo "version=${version}" >>"$GITHUB_OUTPUT"
+ fi
+ echo "$version"
+}
+
+# Check whether a distribution+version is already published on PyPI. This is the
+# release gate (like release-plz checking the registry): the git tag is just a
+# record, the registry is the source of truth. Writes exists=true|false to
+# $GITHUB_OUTPUT. A non-200/404 response is treated as fatal rather than
+# silently re-publishing.
+pypi_exists() {
+ local dist="$1"
+ local version="$2"
+ local url="https://pypi.org/pypi/${dist}/${version}/json"
+
+ # Retry transient failures so a network blip doesn't fail the release gate.
+ local code
+ code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 --retry 3 --retry-connrefused "$url" 2>/dev/null || true)
+
+ local exists
+ case "$code" in
+ 200) exists=true ;;
+ 404) exists=false ;;
+ *)
+ echo "Unexpected status $code querying $url" >&2
+ exit 1
+ ;;
+ esac
+
+ if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
+ echo "exists=${exists}" >>"$GITHUB_OUTPUT"
+ fi
+ echo "PyPI ${dist}==${version}: exists=${exists}"
+}
+
+# Dispatch subcommands
+case "${1:-}" in
+ parse-version) parse_version "$2" ;;
+ prev-tag) prev_tag "$2" ;;
+ create) create_release "$2" ;;
+ read-version) read_version "$2" ;;
+ pypi-exists) pypi_exists "$2" "$3" ;;
+ *)
+ echo "Usage: $0 {parse-version|prev-tag|create|read-version|pypi-exists} " >&2
+ exit 1
+ ;;
+esac
diff --git a/.github/scripts/render-formula.sh b/.github/scripts/render-formula.sh
new file mode 100755
index 0000000000..e156144f70
--- /dev/null
+++ b/.github/scripts/render-formula.sh
@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Render a Homebrew formula template by substituting __VERSION__ and the
+# four __SHA256___ placeholders.
+#
+# Usage:
+# render-formula.sh \
+# --template \
+# --version \
+# --release-dir \
+# --crate \
+# --output
+#
+# The release dir must contain the four tarballs named like
+# --.tar.gz for each of:
+# aarch64-apple-darwin
+# x86_64-apple-darwin
+# aarch64-unknown-linux-gnu
+# x86_64-unknown-linux-gnu
+
+TEMPLATE=""
+VERSION=""
+RELEASE_DIR=""
+CRATE=""
+OUTPUT=""
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --template | --version | --release-dir | --crate | --output)
+ if [[ $# -lt 2 ]]; then
+ echo "Error: $1 requires a value" >&2
+ exit 1
+ fi
+ case $1 in
+ --template) TEMPLATE="$2" ;;
+ --version) VERSION="$2" ;;
+ --release-dir) RELEASE_DIR="$2" ;;
+ --crate) CRATE="$2" ;;
+ --output) OUTPUT="$2" ;;
+ esac
+ shift 2
+ ;;
+ -h | --help)
+ echo "Usage: $0 --template T --version V --release-dir D --crate C --output O"
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ exit 1
+ ;;
+ esac
+done
+
+for var in TEMPLATE VERSION RELEASE_DIR CRATE OUTPUT; do
+ if [[ -z "${!var}" ]]; then
+ echo "Error: --${var,,} is required" >&2
+ exit 1
+ fi
+done
+
+if [[ ! -f "$TEMPLATE" ]]; then
+ echo "Error: template not found: $TEMPLATE" >&2
+ exit 1
+fi
+
+targets=(
+ "aarch64-apple-darwin AARCH64_APPLE_DARWIN"
+ "x86_64-apple-darwin X86_64_APPLE_DARWIN"
+ "aarch64-unknown-linux-gnu AARCH64_UNKNOWN_LINUX_GNU"
+ "x86_64-unknown-linux-gnu X86_64_UNKNOWN_LINUX_GNU"
+)
+
+# Read template once and apply all substitutions in-memory.
+rendered=$(cat "$TEMPLATE")
+rendered="${rendered//__VERSION__/$VERSION}"
+
+for entry in "${targets[@]}"; do
+ target="${entry%% *}"
+ placeholder="${entry##* }"
+ tarball="$RELEASE_DIR/$CRATE-$VERSION-$target.tar.gz"
+
+ if [[ ! -f "$tarball" ]]; then
+ echo "Error: tarball not found: $tarball" >&2
+ exit 1
+ fi
+
+ sha=$(sha256sum "$tarball" | awk '{print $1}')
+ echo " $target: $sha"
+ rendered="${rendered//__SHA256_${placeholder}__/$sha}"
+done
+
+# Sanity check: no placeholders left.
+if grep -q '__[A-Z0-9_]\+__' <<<"$rendered"; then
+ echo "Error: unsubstituted placeholders remain:" >&2
+ grep -o '__[A-Z0-9_]\+__' <<<"$rendered" | sort -u >&2
+ exit 1
+fi
+
+mkdir -p "$(dirname "$OUTPUT")"
+printf '%s\n' "$rendered" >"$OUTPUT"
+echo "Wrote: $OUTPUT"
diff --git a/.github/scripts/trigger-repo-publish.sh b/.github/scripts/trigger-repo-publish.sh
new file mode 100755
index 0000000000..5e4a371c7e
--- /dev/null
+++ b/.github/scripts/trigger-repo-publish.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+#
+# Trigger the apt-repo and rpm-repo workflows for a given release tag.
+# Called from each per-binary release workflow after `gh release create`,
+# because release:published events created via GITHUB_TOKEN don't cascade
+# to other workflows automatically.
+#
+# Required env:
+# GH_TOKEN GitHub token with workflow:write
+# TAG Release tag, e.g. moq-relay-v1.2.3
+
+set -euo pipefail
+
+TAG="${TAG:?TAG must be set}"
+
+echo "Dispatching apt-repo.yml for tag $TAG..."
+gh workflow run apt-repo.yml -f "tag=$TAG"
+
+echo "Dispatching rpm-repo.yml for tag $TAG..."
+gh workflow run rpm-repo.yml -f "tag=$TAG"
diff --git a/.github/workflows/apt-repo.yml b/.github/workflows/apt-repo.yml
new file mode 100644
index 0000000000..c8f96738e0
--- /dev/null
+++ b/.github/workflows/apt-repo.yml
@@ -0,0 +1,62 @@
+name: apt-repo
+
+# Mirrors .deb artifacts attached to a release into the apt.moq.dev repo.
+# Invoked via workflow_dispatch from each per-binary release workflow's
+# final job (gh workflow run apt-repo.yml -f tag=...), or manually.
+#
+# Note: we do NOT trigger on release:published because releases created
+# with the default GITHUB_TOKEN don't cascade to other workflows. The
+# per-binary workflows call us explicitly instead.
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Release tag to ingest (e.g. moq-relay-v1.2.3)"
+ required: true
+
+permissions:
+ contents: read
+
+# Only one apt-repo job can mutate the bucket at a time. Concurrent
+# invocations queue and run in order.
+concurrency:
+ group: apt-repo
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ name: Publish to apt.moq.dev
+ runs-on: ubuntu-22.04
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Set up packaging tools
+ uses: ./.github/actions/setup-packaging
+
+ - name: Download .deb assets from release
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ inputs.tag }}
+ run: |
+ mkdir -p artifacts
+ gh release download "$TAG" \
+ --repo "${{ github.repository }}" \
+ --dir artifacts \
+ --pattern "*.deb"
+ ls -la artifacts/
+
+ - name: Publish to R2
+ shell: bash
+ env:
+ ARTIFACTS_DIR: artifacts
+ R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
+ R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+ R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
+ SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
+ SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
+ run: ./infra/apt/publish.sh
diff --git a/.github/workflows/cachix.yml b/.github/workflows/cachix.yml
index d2c317c54b..e3674b23e3 100644
--- a/.github/workflows/cachix.yml
+++ b/.github/workflows/cachix.yml
@@ -5,26 +5,66 @@ on:
tags:
- 'moq-relay-v*'
- 'moq-token-cli-v*'
- - 'hang-cli-v*'
+ - 'moq-cli-v*'
+ - 'moq-boy-v*'
+ - 'libmoq-v*'
+ - 'moq-gst-v*'
jobs:
- cache:
- name: Build and push to Cachix
- runs-on: ubuntu-latest
+ # Build and push only the package the tag names. The tag prefix
+ # (e.g. moq-relay) maps 1:1 to a flake attribute.
+ release:
+ name: Release (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
permissions:
contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ os:
+ - ubuntu-latest
+ - macos-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ # Full-match REF_NAME against the same -v contract
+ # release.sh enforces, so a malformed tag fails fast instead of
+ # building under a trusted identity.
+ - name: Derive package from tag
+ id: pkg
+ env:
+ REF_NAME: ${{ github.ref_name }}
+ run: |
+ re='^(moq-relay|moq-cli|moq-token-cli|moq-boy|libmoq|moq-gst)-v([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?)$'
+ if [[ ! "$REF_NAME" =~ $re ]]; then
+ echo "Tag format not recognized: $REF_NAME" >&2
+ exit 1
+ fi
+ echo "name=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
- - uses: DeterminateSystems/nix-installer-action@main
- - uses: DeterminateSystems/magic-nix-cache-action@main
+ - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main
+ with:
+ determinate: false
+ - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main
- - uses: cachix/cachix-action@v16
+ - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: kixelated
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- - run: nix build ./rs --print-build-logs
-
- - run: cachix push kixelated ./result
+ - name: Build and cache
+ env:
+ PACKAGE: ${{ steps.pkg.outputs.name }}
+ OS: ${{ matrix.os }}
+ run: |
+ nix build ".#$PACKAGE" --print-build-logs
+ cachix push kixelated ./result
+ # Pin so released paths are exempt from GC, otherwise a consumer
+ # pinning a few-weeks-old tag silently falls back to building once
+ # the cache GCs the path. keep-revisions bounds storage for the
+ # free plan; the pin name is per package+OS so each platform keeps
+ # its own last-N releases instead of clobbering the other's.
+ cachix pin kixelated "$PACKAGE-$OS" ./result --keep-revisions 10
diff --git a/.github/workflows/check-swift.yml b/.github/workflows/check-swift.yml
new file mode 100644
index 0000000000..4d1dfc040e
--- /dev/null
+++ b/.github/workflows/check-swift.yml
@@ -0,0 +1,45 @@
+name: Check Swift
+
+permissions:
+ id-token: write
+ contents: read
+
+# Swift coverage runs on its own macOS workflow because `swift test` needs
+# the macOS toolchain. Gated on Swift sources and `rs/moq-ffi` (which
+# Swift bundles via uniffi).
+on:
+ pull_request:
+ paths:
+ - 'swift/**'
+ - 'rs/moq-ffi/**'
+ - 'justfile'
+ - '.github/workflows/check-swift.yml'
+
+concurrency:
+ group: check-swift-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ check:
+ name: Check
+ runs-on: macos-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main
+ with:
+ determinate: false
+ - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main
+
+ - name: Rust Cache
+ uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
+ with:
+ cache-on-failure: true
+
+ # No FILES arg = force-run; the workflow's paths filter is
+ # already the gate for whether to fire at all.
+ - run: nix develop --command just swift ci
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 291aaec533..b8bc43ee55 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -6,7 +6,11 @@ permissions:
on:
pull_request:
- branches: ["main"]
+
+# Cancel in-flight runs when a new commit lands on the same PR.
+concurrency:
+ group: check-${{ github.ref }}
+ cancel-in-progress: true
jobs:
check:
@@ -14,18 +18,30 @@ jobs:
runs-on: ubuntu-latest
steps:
+ - name: Free disk space
+ uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main
+ with:
+ tool-cache: false
+
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+ # Full history so `just changed` can diff against origin/$GITHUB_BASE_REF.
+ fetch-depth: 0
- - uses: DeterminateSystems/nix-installer-action@main
- - uses: DeterminateSystems/magic-nix-cache-action@main
- - uses: DeterminateSystems/flake-checker-action@main
+ - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main
+ with:
+ determinate: false
+ - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main
+ - uses: DeterminateSystems/flake-checker-action@89c01715e5d0dfdc0a1bfe9d59891ee4945c1483 # main
# Cache Rust dependencies and build artifacts
- name: Rust Cache
- uses: Swatinem/rust-cache@v2
+ uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
cache-on-failure: true
- # Run checks with cached dependencies
- - run: nix develop --command just check-all
+ # `just ci` calls `just changed` internally to skip unchanged scopes.
+ - run: nix develop --command just ci
+
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 01b3e80172..fb49c2d328 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -4,62 +4,105 @@ on:
push:
tags:
- 'moq-relay-v*'
- - 'moq-clock-v*'
- # NOTE: The -cli is automatically removed
- - 'hang-cli-v*'
+ - 'moq-cli-v*'
- 'moq-token-cli-v*'
env:
- REGISTRY: docker.io/kixelated
+ REGISTRY: docker.io/moqdev
jobs:
- deploy:
- name: Release
-
+ parse:
+ name: Parse tag
runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- id-token: write
-
+ permissions: {}
+ outputs:
+ target: ${{ steps.parse.outputs.target }}
+ version: ${{ steps.parse.outputs.version }}
steps:
- - uses: actions/checkout@v6
-
- # Figure out the docker image based on the tag
- id: parse
run: |
ref=${GITHUB_REF#refs/tags/}
if [[ "$ref" =~ ^([a-z-]+)-v([0-9.]+)$ ]]; then
- full_target="${BASH_REMATCH[1]}"
- version="${BASH_REMATCH[2]}"
- # Strip "-cli" suffix from target if present
- target="${full_target%-cli}"
- echo "target=$target" >> $GITHUB_OUTPUT
- echo "version=$version" >> $GITHUB_OUTPUT
+ echo "target=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
+ echo "version=${BASH_REMATCH[2]}" >> "$GITHUB_OUTPUT"
else
echo "Tag format not recognized." >&2
exit 1
fi
- # I'm paying for Depot for faster ARM builds.
- - uses: depot/setup-action@v1
+ build:
+ name: Build ${{ matrix.platform }}
+ needs: parse
+ strategy:
+ fail-fast: true
+ matrix:
+ include:
+ - platform: linux/amd64
+ runner: ubuntu-latest
+ - platform: linux/arm64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- # Login to docker.io
- - uses: docker/login-action@v3
+ - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- # Build and push multi-arch Docker images using Depot
- - uses: depot/build-push-action@v1
+ # Build and push by digest. The manifest list is assembled in the merge job.
+ - id: build
+ uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
- project: r257ctfqm6
context: .
- push: true
- # Make a smaller image by specifying the package to build
+ platforms: ${{ matrix.platform }}
build-args: |
- package=${{ steps.parse.outputs.target }}
- tags: |
- ${{ env.REGISTRY }}/${{ steps.parse.outputs.target }}:${{ steps.parse.outputs.version }}
- ${{ env.REGISTRY }}/${{ steps.parse.outputs.target }}:latest
- platforms: linux/amd64,linux/arm64
+ package=${{ needs.parse.outputs.target }}
+ outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.parse.outputs.target }},push-by-digest=true,name-canonical=true,push=true
+
+ - name: Export digest
+ run: |
+ mkdir -p /tmp/digests
+ digest="${{ steps.build.outputs.digest }}"
+ touch "/tmp/digests/${digest#sha256:}"
+
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: digests-${{ strategy.job-index }}
+ path: /tmp/digests/*
+ if-no-files-found: error
+ retention-days: 1
+
+ merge:
+ name: Publish manifest
+ needs: [parse, build]
+ runs-on: ubuntu-latest
+ permissions: {}
+ steps:
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ path: /tmp/digests
+ pattern: digests-*
+ merge-multiple: true
+
+ - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+
+ - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Create manifest
+ working-directory: /tmp/digests
+ run: |
+ # shellcheck disable=SC2046 # intentional word-splitting: one arg per digest
+ docker buildx imagetools create \
+ -t ${{ env.REGISTRY }}/${{ needs.parse.outputs.target }}:${{ needs.parse.outputs.version }} \
+ -t ${{ env.REGISTRY }}/${{ needs.parse.outputs.target }}:latest \
+ $(printf "${{ env.REGISTRY }}/${{ needs.parse.outputs.target }}@sha256:%s " *)
diff --git a/.github/workflows/libmoq.yml b/.github/workflows/libmoq.yml
index 185d227693..f6d6fd7f5f 100644
--- a/.github/workflows/libmoq.yml
+++ b/.github/workflows/libmoq.yml
@@ -4,9 +4,14 @@ on:
push:
tags:
- "libmoq-v*"
+ pull_request:
+ paths:
+ - ".github/workflows/libmoq.yml"
+ - ".github/scripts/release.sh"
+ - "rs/libmoq/build.sh"
permissions:
- contents: write
+ contents: read
jobs:
build:
@@ -19,39 +24,53 @@ jobs:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
+ use_nix: true
- target: aarch64-unknown-linux-gnu
- os: ubuntu-latest
+ os: ubuntu-24.04-arm
+ use_nix: true
- target: x86_64-apple-darwin
os: macos-15-intel
+ use_nix: true
- target: aarch64-apple-darwin
os: macos-latest
+ use_nix: true
- target: x86_64-pc-windows-msvc
os: windows-latest
+ use_nix: false
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
- - name: Install Rust
- uses: dtolnay/rust-toolchain@stable
+ - name: Install Nix
+ if: matrix.use_nix
+ uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main
with:
- targets: ${{ matrix.target }}
+ determinate: false
+ - name: Configure Nix cache
+ if: matrix.use_nix
+ uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main
- - name: Install cross-compilation tools (Linux ARM64)
- if: matrix.target == 'aarch64-unknown-linux-gnu'
- run: |
- sudo apt-get update
- sudo apt-get install -y gcc-aarch64-linux-gnu
+ - name: Install Rust (Windows)
+ if: '!matrix.use_nix'
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ with:
+ targets: ${{ matrix.target }}
- name: Parse version
id: parse
shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
run: |
- ref=${GITHUB_REF#refs/tags/}
- if [[ "$ref" =~ ^libmoq-v([0-9.]+)$ ]]; then
- echo "version=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
+ if [[ "$EVENT_NAME" == "pull_request" ]]; then
+ CARGO_VERSION=$(grep '^version' rs/libmoq/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
+ echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT"
+ echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION"
else
- echo "Tag format not recognized: $ref" >&2
- exit 1
+ .github/scripts/release.sh parse-version libmoq
fi
- name: Build and package
@@ -63,7 +82,7 @@ jobs:
--output dist
- name: Upload artifact
- uses: actions/upload-artifact@v6
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: libmoq-${{ matrix.target }}
path: dist/*
@@ -72,28 +91,34 @@ jobs:
name: Release
needs: build
runs-on: ubuntu-latest
+ if: github.event_name == 'push'
+ permissions:
+ contents: write
steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
- name: Parse version
id: parse
- run: |
- ref=${GITHUB_REF#refs/tags/}
- if [[ "$ref" =~ ^libmoq-v([0-9.]+)$ ]]; then
- echo "version=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
- else
- echo "Tag format not recognized: $ref" >&2
- exit 1
- fi
+ run: .github/scripts/release.sh parse-version libmoq
+
+ - name: Find previous tag
+ id: prev_tag
+ run: .github/scripts/release.sh prev-tag libmoq
- name: Download artifacts
- uses: actions/download-artifact@v7
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: artifacts
merge-multiple: true
- - name: Create release
- uses: softprops/action-gh-release@v2
- with:
- name: libmoq v${{ steps.parse.outputs.version }}
- generate_release_notes: true
- files: artifacts/*
+ - name: Create or update release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ github.ref_name }}
+ RELEASE_TITLE: "libmoq v${{ steps.parse.outputs.version }}"
+ RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }}
+ run: .github/scripts/release.sh create artifacts
diff --git a/.github/workflows/moq-cli.yml b/.github/workflows/moq-cli.yml
new file mode 100644
index 0000000000..5e0ebc9d71
--- /dev/null
+++ b/.github/workflows/moq-cli.yml
@@ -0,0 +1,236 @@
+name: moq-cli
+
+on:
+ push:
+ tags:
+ - "moq-cli-v*"
+ pull_request:
+ paths:
+ - ".github/workflows/moq-cli.yml"
+ - ".github/scripts/release.sh"
+ - "rs/scripts/package-binary.sh"
+ - "rs/scripts/scrub-macho.sh"
+ - "packaging/moq-cli/**"
+ - ".github/actions/setup-packaging/**"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build (${{ matrix.target }})
+ runs-on: ${{ matrix.runs-on }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-unknown-linux-gnu
+ runs-on: ubuntu-22.04
+ deb-arch: amd64
+ rpm-arch: x86_64
+ - target: aarch64-unknown-linux-gnu
+ runs-on: ubuntu-22.04-arm
+ deb-arch: arm64
+ rpm-arch: aarch64
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Parse version
+ id: parse
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ run: |
+ if [[ "$EVENT_NAME" == "pull_request" ]]; then
+ CARGO_VERSION=$(grep '^version' rs/moq-cli/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
+ echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT"
+ echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION"
+ else
+ .github/scripts/release.sh parse-version moq-cli
+ fi
+
+ - name: Set up packaging tools
+ uses: ./.github/actions/setup-packaging
+
+ - name: Build (release, glibc 2.34)
+ shell: bash
+ # Pin to glibc 2.34 so one binary covers Ubuntu 22.04+ (.deb)
+ # and AlmaLinux/RHEL/Rocky 9+ (.rpm).
+ run: cargo zigbuild --release --target ${{ matrix.target }}.2.34 -p moq-cli
+
+ - name: Rename binary
+ shell: bash
+ env:
+ TARGET: ${{ matrix.target }}
+ run: |
+ # The crate is named moq-cli but the .deb / .rpm and Homebrew
+ # formula present the binary as `moq`. Cargo produces
+ # target/release/moq-cli by default (no [[bin]] override),
+ # so make a sibling copy that the downstream packaging steps
+ # can point at without each one needing to know the rename.
+ cp "target/${TARGET}/release/moq-cli" "target/${TARGET}/release/moq"
+
+ - name: Package bare binary
+ shell: bash
+ run: |
+ version="${{ steps.parse.outputs.version }}"
+ target="${{ matrix.target }}"
+ name="moq-cli-v${version}-${target}"
+ mkdir -p dist
+ cp "target/${target}/release/moq" "dist/${name}"
+
+ - name: Package tarball (Homebrew)
+ shell: bash
+ env:
+ VERSION: ${{ steps.parse.outputs.version }}
+ TARGET: ${{ matrix.target }}
+ run: |
+ name="moq-cli-${VERSION}-${TARGET}"
+ mkdir -p "dist/${name}/bin"
+ cp "target/${TARGET}/release/moq-cli" "dist/${name}/bin/moq-cli"
+ cp LICENSE-MIT LICENSE-APACHE "dist/${name}/"
+ if [[ -f rs/moq-cli/README.md ]]; then
+ cp rs/moq-cli/README.md "dist/${name}/"
+ fi
+ (cd dist && tar -czvf "${name}.tar.gz" "${name}" && rm -rf "${name}")
+
+ - name: Package .deb (Ubuntu 22.04 toolchain)
+ shell: bash
+ env:
+ VERSION: ${{ steps.parse.outputs.version }}
+ ARCH: ${{ matrix.deb-arch }}
+ BINARY_PATH: target/${{ matrix.target }}/release/moq
+ run: |
+ # nfpm doesn't expand ${VAR} in contents[].src or templating
+ # there either, so render the env vars in ourselves.
+ # shellcheck disable=SC2016 # envsubst varlist must be single-quoted
+ envsubst '$VERSION $ARCH $BINARY_PATH' \
+ < packaging/moq-cli/nfpm.yaml > /tmp/nfpm.yaml
+ nfpm pkg --packager deb --config /tmp/nfpm.yaml --target dist/
+
+ - name: Package .rpm
+ shell: bash
+ env:
+ VERSION: ${{ steps.parse.outputs.version }}
+ ARCH: ${{ matrix.rpm-arch }}
+ BINARY_PATH: target/${{ matrix.target }}/release/moq
+ run: |
+ # shellcheck disable=SC2016 # envsubst varlist must be single-quoted
+ envsubst '$VERSION $ARCH $BINARY_PATH' \
+ < packaging/moq-cli/nfpm.yaml > /tmp/nfpm.yaml
+ nfpm pkg --packager rpm --config /tmp/nfpm.yaml --target dist/
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: moq-cli-${{ matrix.target }}
+ path: dist/*
+
+ build-tarball-macos:
+ name: Build macOS tarball (${{ matrix.target }})
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-apple-darwin
+ os: macos-15-intel
+ - target: aarch64-apple-darwin
+ os: macos-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main
+ with:
+ determinate: false
+ - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main
+
+ - name: Parse version
+ id: parse
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ run: |
+ if [[ "$EVENT_NAME" == "pull_request" ]]; then
+ CARGO_VERSION=$(grep '^version' rs/moq-cli/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
+ echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT"
+ echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION"
+ else
+ .github/scripts/release.sh parse-version moq-cli
+ fi
+
+ - name: Build and package tarball
+ shell: bash
+ run: |
+ ./rs/scripts/package-binary.sh \
+ --crate moq-cli \
+ --target ${{ matrix.target }} \
+ --version ${{ steps.parse.outputs.version }} \
+ --output dist
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: moq-cli-${{ matrix.target }}
+ path: dist/*
+
+ release:
+ name: Release
+ needs: [build, build-tarball-macos]
+ runs-on: ubuntu-latest
+ if: github.event_name == 'push'
+ permissions:
+ contents: write
+ actions: write
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Parse version
+ id: parse
+ run: .github/scripts/release.sh parse-version moq-cli
+
+ - name: Find previous tag
+ id: prev_tag
+ run: .github/scripts/release.sh prev-tag moq-cli
+
+ - name: Download artifacts
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ path: artifacts
+ merge-multiple: true
+
+ - name: Create or update release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ github.ref_name }}
+ RELEASE_TITLE: "moq-cli v${{ steps.parse.outputs.version }}"
+ RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }}
+ run: .github/scripts/release.sh create artifacts
+
+ - name: Trigger apt/rpm repo publish
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ github.ref_name }}
+ run: .github/scripts/trigger-repo-publish.sh
diff --git a/.github/workflows/moq-gst.yml b/.github/workflows/moq-gst.yml
new file mode 100644
index 0000000000..de82ce5c38
--- /dev/null
+++ b/.github/workflows/moq-gst.yml
@@ -0,0 +1,219 @@
+name: moq-gst
+
+on:
+ push:
+ tags:
+ - "moq-gst-v*"
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build tarball (${{ matrix.target }})
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-unknown-linux-gnu
+ os: ubuntu-latest
+ - target: aarch64-unknown-linux-gnu
+ os: ubuntu-24.04-arm
+ - target: x86_64-apple-darwin
+ os: macos-15-intel
+ - target: aarch64-apple-darwin
+ os: macos-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main
+ with:
+ determinate: false
+ - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main
+
+ - name: Parse version
+ id: parse
+ shell: bash
+ run: .github/scripts/release.sh parse-version moq-gst
+
+ - name: Build and package (tarball)
+ shell: bash
+ env:
+ TARGET: ${{ matrix.target }}
+ VERSION: ${{ steps.parse.outputs.version }}
+ run: |
+ ./rs/moq-gst/build.sh \
+ --target "$TARGET" \
+ --version "$VERSION" \
+ --output dist
+
+ # Load the produced plugin against a system GStreamer the runner just
+ # installed (separate from the nix copy the build linked against).
+ # This is the customer-facing scenario: `nix build .#moq-gst` then
+ # `gst-inspect-1.0 moq`. If dyld/ld.so can't resolve the plugin's deps
+ # against the system GStreamer, this step fails.
+ - name: Install system GStreamer
+ shell: bash
+ run: |
+ if [[ "$RUNNER_OS" == "Linux" ]]; then
+ sudo apt-get update -qq
+ sudo apt-get install -y --no-install-recommends \
+ gstreamer1.0-tools \
+ gstreamer1.0-plugins-base \
+ gstreamer1.0-plugins-good
+ else
+ brew install gstreamer
+ fi
+
+ - name: Smoke test (gst-inspect against system GStreamer)
+ shell: bash
+ env:
+ TARGET: ${{ matrix.target }}
+ VERSION: ${{ steps.parse.outputs.version }}
+ run: |
+ set -euo pipefail
+ tar -xzf "dist/moq-gst-${VERSION}-${TARGET}.tar.gz" -C dist/
+ ./rs/moq-gst/smoke.sh "dist/moq-gst-${VERSION}-${TARGET}/lib/gstreamer-1.0"
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: moq-gst-${{ matrix.target }}
+ path: dist/*.tar.gz
+
+ package:
+ name: Package .deb/.rpm (${{ matrix.target }})
+ runs-on: ${{ matrix.runs-on }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-unknown-linux-gnu
+ runs-on: ubuntu-24.04
+ deb-arch: amd64
+ deb-multiarch: x86_64-linux-gnu
+ rpm-arch: x86_64
+ - target: aarch64-unknown-linux-gnu
+ runs-on: ubuntu-24.04-arm
+ deb-arch: arm64
+ deb-multiarch: aarch64-linux-gnu
+ rpm-arch: aarch64
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Set up packaging tools
+ uses: ./.github/actions/setup-packaging
+
+ - name: Parse version
+ id: parse
+ shell: bash
+ run: .github/scripts/release.sh parse-version moq-gst
+
+ - name: Install GStreamer dev libraries
+ shell: bash
+ run: |
+ # Ubuntu 24.04 ships GStreamer 1.24, satisfying gstreamer-rs
+ # 0.23's 1.22+ headers requirement. Resulting .so dynamic-links
+ # to libgstreamer-1.0.so.0 (ABI-stable since 1.0).
+ sudo apt-get update -qq
+ sudo apt-get install -y --no-install-recommends \
+ libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
+
+ - name: Build (release, glibc 2.34)
+ shell: bash
+ # Pin glibc to 2.34 so one .so covers Debian 12+/Ubuntu 22.04+
+ # (.deb) and AlmaLinux/RHEL/Rocky 9+ (.rpm).
+ run: cargo zigbuild --release --target ${{ matrix.target }}.2.34 -p moq-gst
+
+ - name: Package .deb
+ shell: bash
+ env:
+ VERSION: ${{ steps.parse.outputs.version }}
+ ARCH: ${{ matrix.deb-arch }}
+ PKG_NAME: gstreamer1.0-moq
+ PLUGIN_PATH: target/${{ matrix.target }}/release/libgstmoq.so
+ PLUGIN_DIR: /usr/lib/${{ matrix.deb-multiarch }}/gstreamer-1.0
+ run: |
+ mkdir -p dist
+ # shellcheck disable=SC2016 # envsubst varlist must be single-quoted
+ envsubst '$VERSION $ARCH $PKG_NAME $PLUGIN_PATH $PLUGIN_DIR' \
+ < packaging/moq-gst/nfpm.yaml > /tmp/nfpm.yaml
+ nfpm pkg --packager deb --config /tmp/nfpm.yaml --target dist/
+
+ - name: Package .rpm
+ shell: bash
+ env:
+ VERSION: ${{ steps.parse.outputs.version }}
+ ARCH: ${{ matrix.rpm-arch }}
+ PKG_NAME: gstreamer1-moq
+ PLUGIN_PATH: target/${{ matrix.target }}/release/libgstmoq.so
+ PLUGIN_DIR: /usr/lib64/gstreamer-1.0
+ run: |
+ mkdir -p dist
+ # shellcheck disable=SC2016 # envsubst varlist must be single-quoted
+ envsubst '$VERSION $ARCH $PKG_NAME $PLUGIN_PATH $PLUGIN_DIR' \
+ < packaging/moq-gst/nfpm.yaml > /tmp/nfpm.yaml
+ nfpm pkg --packager rpm --config /tmp/nfpm.yaml --target dist/
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: moq-gst-pkg-${{ matrix.target }}
+ path: dist/*
+
+ release:
+ name: Release
+ needs: [build, package]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ actions: write
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Parse version
+ id: parse
+ run: .github/scripts/release.sh parse-version moq-gst
+
+ - name: Find previous tag
+ id: prev_tag
+ run: .github/scripts/release.sh prev-tag moq-gst
+
+ - name: Download artifacts
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ path: artifacts
+ merge-multiple: true
+
+ - name: Create or update release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ github.ref_name }}
+ RELEASE_TITLE: "moq-gst v${{ steps.parse.outputs.version }}"
+ RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }}
+ run: .github/scripts/release.sh create artifacts
+
+ - name: Trigger apt/rpm repo publish
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ github.ref_name }}
+ run: .github/scripts/trigger-repo-publish.sh
diff --git a/.github/workflows/moq-relay.yml b/.github/workflows/moq-relay.yml
new file mode 100644
index 0000000000..2f5df0eb23
--- /dev/null
+++ b/.github/workflows/moq-relay.yml
@@ -0,0 +1,225 @@
+name: moq-relay
+
+on:
+ push:
+ tags:
+ - "moq-relay-v*"
+ pull_request:
+ paths:
+ - ".github/workflows/moq-relay.yml"
+ - ".github/scripts/release.sh"
+ - "rs/scripts/package-binary.sh"
+ - "rs/scripts/scrub-macho.sh"
+ - "packaging/moq-relay/**"
+ - ".github/actions/setup-packaging/**"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build (${{ matrix.target }})
+ runs-on: ${{ matrix.runs-on }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # Native runners. Each binary is built on the same arch it ships
+ # for, so the .deb/.rpm linkage matches the distro's glibc on the
+ # oldest LTS we support (Ubuntu 22.04 / AlmaLinux 9).
+ - target: x86_64-unknown-linux-gnu
+ runs-on: ubuntu-22.04
+ deb-arch: amd64
+ rpm-arch: x86_64
+ - target: aarch64-unknown-linux-gnu
+ runs-on: ubuntu-22.04-arm
+ deb-arch: arm64
+ rpm-arch: aarch64
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Parse version
+ id: parse
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ run: |
+ if [[ "$EVENT_NAME" == "pull_request" ]]; then
+ CARGO_VERSION=$(grep '^version' rs/moq-relay/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
+ echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT"
+ echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION"
+ else
+ .github/scripts/release.sh parse-version moq-relay
+ fi
+
+ - name: Set up packaging tools
+ uses: ./.github/actions/setup-packaging
+
+ - name: Build (release, glibc 2.34)
+ shell: bash
+ # Pin to glibc 2.34 so one binary covers Ubuntu 22.04+ (.deb)
+ # and AlmaLinux/RHEL/Rocky 9+ (.rpm).
+ run: cargo zigbuild --release --target ${{ matrix.target }}.2.34 -p moq-relay
+
+ - name: Package bare binary
+ shell: bash
+ run: |
+ version="${{ steps.parse.outputs.version }}"
+ target="${{ matrix.target }}"
+ name="moq-relay-v${version}-${target}"
+ mkdir -p dist
+ cp "target/${target}/release/moq-relay" "dist/${name}"
+
+ - name: Package tarball (Homebrew)
+ shell: bash
+ env:
+ VERSION: ${{ steps.parse.outputs.version }}
+ TARGET: ${{ matrix.target }}
+ run: |
+ name="moq-relay-${VERSION}-${TARGET}"
+ mkdir -p "dist/${name}/bin"
+ cp "target/${TARGET}/release/moq-relay" "dist/${name}/bin/moq-relay"
+ cp LICENSE-MIT LICENSE-APACHE "dist/${name}/"
+ if [[ -f rs/moq-relay/README.md ]]; then
+ cp rs/moq-relay/README.md "dist/${name}/"
+ fi
+ (cd dist && tar -czvf "${name}.tar.gz" "${name}" && rm -rf "${name}")
+
+ - name: Package .deb (Ubuntu 22.04 toolchain, glibc 2.35)
+ shell: bash
+ env:
+ VERSION: ${{ steps.parse.outputs.version }}
+ ARCH: ${{ matrix.deb-arch }}
+ BINARY_PATH: target/${{ matrix.target }}/release/moq-relay
+ run: |
+ # shellcheck disable=SC2016 # envsubst varlist must be single-quoted
+ envsubst '$VERSION $ARCH $BINARY_PATH' \
+ < packaging/moq-relay/nfpm.yaml > /tmp/nfpm.yaml
+ nfpm pkg --packager deb --config /tmp/nfpm.yaml --target dist/
+
+ - name: Package .rpm
+ shell: bash
+ env:
+ VERSION: ${{ steps.parse.outputs.version }}
+ ARCH: ${{ matrix.rpm-arch }}
+ BINARY_PATH: target/${{ matrix.target }}/release/moq-relay
+ run: |
+ # shellcheck disable=SC2016 # envsubst varlist must be single-quoted
+ envsubst '$VERSION $ARCH $BINARY_PATH' \
+ < packaging/moq-relay/nfpm.yaml > /tmp/nfpm.yaml
+ nfpm pkg --packager rpm --config /tmp/nfpm.yaml --target dist/
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: moq-relay-${{ matrix.target }}
+ path: dist/*
+
+ build-tarball-macos:
+ name: Build macOS tarball (${{ matrix.target }})
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-apple-darwin
+ os: macos-15-intel
+ - target: aarch64-apple-darwin
+ os: macos-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main
+ with:
+ determinate: false
+ - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main
+
+ - name: Parse version
+ id: parse
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ run: |
+ if [[ "$EVENT_NAME" == "pull_request" ]]; then
+ CARGO_VERSION=$(grep '^version' rs/moq-relay/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
+ echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT"
+ echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION"
+ else
+ .github/scripts/release.sh parse-version moq-relay
+ fi
+
+ - name: Build and package tarball
+ shell: bash
+ run: |
+ ./rs/scripts/package-binary.sh \
+ --crate moq-relay \
+ --target ${{ matrix.target }} \
+ --version ${{ steps.parse.outputs.version }} \
+ --output dist
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: moq-relay-${{ matrix.target }}
+ path: dist/*
+
+ release:
+ name: Release
+ needs: [build, build-tarball-macos]
+ runs-on: ubuntu-latest
+ if: github.event_name == 'push'
+ permissions:
+ contents: write
+ actions: write
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Parse version
+ id: parse
+ run: .github/scripts/release.sh parse-version moq-relay
+
+ - name: Find previous tag
+ id: prev_tag
+ run: .github/scripts/release.sh prev-tag moq-relay
+
+ - name: Download artifacts
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ path: artifacts
+ merge-multiple: true
+
+ - name: Create or update release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ github.ref_name }}
+ RELEASE_TITLE: "moq-relay v${{ steps.parse.outputs.version }}"
+ RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }}
+ run: .github/scripts/release.sh create artifacts
+
+ - name: Trigger apt/rpm repo publish
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ github.ref_name }}
+ run: .github/scripts/trigger-repo-publish.sh
diff --git a/.github/workflows/moq-token-cli.yml b/.github/workflows/moq-token-cli.yml
new file mode 100644
index 0000000000..b5407edaba
--- /dev/null
+++ b/.github/workflows/moq-token-cli.yml
@@ -0,0 +1,222 @@
+name: moq-token-cli
+
+on:
+ push:
+ tags:
+ - "moq-token-cli-v*"
+ pull_request:
+ paths:
+ - ".github/workflows/moq-token-cli.yml"
+ - ".github/scripts/release.sh"
+ - "rs/scripts/package-binary.sh"
+ - "rs/scripts/scrub-macho.sh"
+ - "packaging/moq-token-cli/**"
+ - ".github/actions/setup-packaging/**"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build (${{ matrix.target }})
+ runs-on: ${{ matrix.runs-on }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-unknown-linux-gnu
+ runs-on: ubuntu-22.04
+ deb-arch: amd64
+ rpm-arch: x86_64
+ - target: aarch64-unknown-linux-gnu
+ runs-on: ubuntu-22.04-arm
+ deb-arch: arm64
+ rpm-arch: aarch64
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Parse version
+ id: parse
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ run: |
+ if [[ "$EVENT_NAME" == "pull_request" ]]; then
+ CARGO_VERSION=$(grep '^version' rs/moq-token-cli/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
+ echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT"
+ echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION"
+ else
+ .github/scripts/release.sh parse-version moq-token-cli
+ fi
+
+ - name: Set up packaging tools
+ uses: ./.github/actions/setup-packaging
+
+ - name: Build (release, glibc 2.34)
+ shell: bash
+ # Pin to glibc 2.34 so one binary covers Ubuntu 22.04+ (.deb)
+ # and AlmaLinux/RHEL/Rocky 9+ (.rpm).
+ run: cargo zigbuild --release --target ${{ matrix.target }}.2.34 -p moq-token-cli
+
+ - name: Package bare binary
+ shell: bash
+ run: |
+ version="${{ steps.parse.outputs.version }}"
+ target="${{ matrix.target }}"
+ name="moq-token-cli-v${version}-${target}"
+ mkdir -p dist
+ cp "target/${target}/release/moq-token-cli" "dist/${name}"
+
+ - name: Package tarball (Homebrew)
+ shell: bash
+ env:
+ VERSION: ${{ steps.parse.outputs.version }}
+ TARGET: ${{ matrix.target }}
+ run: |
+ name="moq-token-cli-${VERSION}-${TARGET}"
+ mkdir -p "dist/${name}/bin"
+ cp "target/${TARGET}/release/moq-token-cli" "dist/${name}/bin/moq-token-cli"
+ cp LICENSE-MIT LICENSE-APACHE "dist/${name}/"
+ if [[ -f rs/moq-token-cli/README.md ]]; then
+ cp rs/moq-token-cli/README.md "dist/${name}/"
+ fi
+ (cd dist && tar -czvf "${name}.tar.gz" "${name}" && rm -rf "${name}")
+
+ - name: Package .deb (Ubuntu 22.04 toolchain)
+ shell: bash
+ env:
+ VERSION: ${{ steps.parse.outputs.version }}
+ ARCH: ${{ matrix.deb-arch }}
+ BINARY_PATH: target/${{ matrix.target }}/release/moq-token-cli
+ run: |
+ # shellcheck disable=SC2016 # envsubst varlist must be single-quoted
+ envsubst '$VERSION $ARCH $BINARY_PATH' \
+ < packaging/moq-token-cli/nfpm.yaml > /tmp/nfpm.yaml
+ nfpm pkg --packager deb --config /tmp/nfpm.yaml --target dist/
+
+ - name: Package .rpm
+ shell: bash
+ env:
+ VERSION: ${{ steps.parse.outputs.version }}
+ ARCH: ${{ matrix.rpm-arch }}
+ BINARY_PATH: target/${{ matrix.target }}/release/moq-token-cli
+ run: |
+ # shellcheck disable=SC2016 # envsubst varlist must be single-quoted
+ envsubst '$VERSION $ARCH $BINARY_PATH' \
+ < packaging/moq-token-cli/nfpm.yaml > /tmp/nfpm.yaml
+ nfpm pkg --packager rpm --config /tmp/nfpm.yaml --target dist/
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: moq-token-cli-${{ matrix.target }}
+ path: dist/*
+
+ build-tarball-macos:
+ name: Build macOS tarball (${{ matrix.target }})
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-apple-darwin
+ os: macos-15-intel
+ - target: aarch64-apple-darwin
+ os: macos-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main
+ with:
+ determinate: false
+ - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main
+
+ - name: Parse version
+ id: parse
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ run: |
+ if [[ "$EVENT_NAME" == "pull_request" ]]; then
+ CARGO_VERSION=$(grep '^version' rs/moq-token-cli/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
+ echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT"
+ echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION"
+ else
+ .github/scripts/release.sh parse-version moq-token-cli
+ fi
+
+ - name: Build and package tarball
+ shell: bash
+ run: |
+ ./rs/scripts/package-binary.sh \
+ --crate moq-token-cli \
+ --target ${{ matrix.target }} \
+ --version ${{ steps.parse.outputs.version }} \
+ --output dist
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: moq-token-cli-${{ matrix.target }}
+ path: dist/*
+
+ release:
+ name: Release
+ needs: [build, build-tarball-macos]
+ runs-on: ubuntu-latest
+ if: github.event_name == 'push'
+ permissions:
+ contents: write
+ actions: write
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Parse version
+ id: parse
+ run: .github/scripts/release.sh parse-version moq-token-cli
+
+ - name: Find previous tag
+ id: prev_tag
+ run: .github/scripts/release.sh prev-tag moq-token-cli
+
+ - name: Download artifacts
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ path: artifacts
+ merge-multiple: true
+
+ - name: Create or update release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ github.ref_name }}
+ RELEASE_TITLE: "moq-token-cli v${{ steps.parse.outputs.version }}"
+ RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }}
+ run: .github/scripts/release.sh create artifacts
+
+ - name: Trigger apt/rpm repo publish
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ github.ref_name }}
+ run: .github/scripts/trigger-repo-publish.sh
diff --git a/.github/workflows/release-brew.yml b/.github/workflows/release-brew.yml
new file mode 100644
index 0000000000..b7f0c54760
--- /dev/null
+++ b/.github/workflows/release-brew.yml
@@ -0,0 +1,233 @@
+name: release-brew
+
+# Renders the matching Homebrew formula template from
+# .github/homebrew/Formula/ with the released version + tarball sha256s,
+# then pushes the rendered formula to moq-dev/homebrew-tap (which Homebrew
+# exposes as the `moq-dev/tap` tap).
+#
+# Triggered after a per-crate release workflow finishes successfully, so
+# the GitHub Release assets are guaranteed to exist by the time we
+# download them.
+#
+# Authenticates as the moq-bot GitHub App. The APP_ID and APP_PRIVATE_KEY
+# repo secrets are already wired (see release-swift.yml). The minted
+# installation token is scoped to moq-dev/homebrew-tap and expires in 1
+# hour.
+
+on:
+ workflow_run:
+ workflows:
+ - moq-relay
+ - moq-cli
+ - moq-token-cli
+ - moq-gst
+ types:
+ - completed
+ pull_request:
+ paths:
+ - ".github/workflows/release-brew.yml"
+ - ".github/scripts/render-formula.sh"
+ - ".github/homebrew/Formula/**"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+ contents: read
+
+jobs:
+ dry-run:
+ name: Dry-run render all templates
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Render every template against synthetic tarballs
+ run: |
+ set -euo pipefail
+ mkdir -p fake-assets rendered
+
+ shopt -s nullglob
+ templates=(.github/homebrew/Formula/*.rb.tmpl)
+ if (( ${#templates[@]} == 0 )); then
+ echo "No templates found under .github/homebrew/Formula/" >&2
+ exit 1
+ fi
+
+ # Use a stable dummy version so any baked-in version assertions in
+ # the templates render deterministically. The sha256s differ per
+ # crate because the fake tarball content includes the crate name.
+ VERSION="0.0.0-dryrun"
+
+ for tmpl in "${templates[@]}"; do
+ crate=$(basename "$tmpl" .rb.tmpl)
+ echo "::group::Render $crate"
+ rm -f fake-assets/*
+ for target in \
+ aarch64-apple-darwin \
+ x86_64-apple-darwin \
+ aarch64-unknown-linux-gnu \
+ x86_64-unknown-linux-gnu
+ do
+ printf 'fake %s %s\n' "$crate" "$target" \
+ > "fake-assets/${crate}-${VERSION}-${target}.tar.gz"
+ done
+
+ ./.github/scripts/render-formula.sh \
+ --template "$tmpl" \
+ --version "$VERSION" \
+ --release-dir fake-assets \
+ --crate "$crate" \
+ --output "rendered/${crate}.rb"
+
+ if grep -E '__[A-Z0-9_]+__' "rendered/${crate}.rb"; then
+ echo "Error: unsubstituted placeholders in rendered/${crate}.rb" >&2
+ exit 1
+ fi
+
+ ruby -c "rendered/${crate}.rb"
+ echo "::endgroup::"
+ done
+
+ publish:
+ name: Publish formula to homebrew-tap
+ if: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'moq-') }}
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Parse crate and version
+ id: parse
+ env:
+ TAG: ${{ github.event.workflow_run.head_branch }}
+ run: |
+ if [[ ! "$TAG" =~ ^(.+)-v([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?)$ ]]; then
+ echo "Tag format not recognized: $TAG" >&2
+ exit 1
+ fi
+ crate="${BASH_REMATCH[1]}"
+ version="${BASH_REMATCH[2]}"
+ {
+ echo "crate=$crate"
+ echo "version=$version"
+ echo "tag=$TAG"
+ } >> "$GITHUB_OUTPUT"
+ echo "Crate: $crate"
+ echo "Version: $version"
+
+ - name: Check template exists
+ env:
+ CRATE: ${{ steps.parse.outputs.crate }}
+ run: |
+ template=".github/homebrew/Formula/${CRATE}.rb.tmpl"
+ if [[ ! -f "$template" ]]; then
+ echo "No Homebrew template for crate: $CRATE (looked for $template)" >&2
+ exit 1
+ fi
+
+ - name: Download release tarballs
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ steps.parse.outputs.tag }}
+ CRATE: ${{ steps.parse.outputs.crate }}
+ VERSION: ${{ steps.parse.outputs.version }}
+ run: |
+ mkdir -p release-assets
+ for target in \
+ aarch64-apple-darwin \
+ x86_64-apple-darwin \
+ aarch64-unknown-linux-gnu \
+ x86_64-unknown-linux-gnu
+ do
+ name="${CRATE}-${VERSION}-${target}.tar.gz"
+ echo "Downloading $name..."
+ gh release download "$TAG" \
+ --repo "${{ github.repository }}" \
+ --pattern "$name" \
+ --dir release-assets
+ done
+ ls -la release-assets
+
+ - name: Render formula
+ env:
+ CRATE: ${{ steps.parse.outputs.crate }}
+ VERSION: ${{ steps.parse.outputs.version }}
+ run: |
+ mkdir -p rendered
+ ./.github/scripts/render-formula.sh \
+ --template ".github/homebrew/Formula/${CRATE}.rb.tmpl" \
+ --version "$VERSION" \
+ --release-dir release-assets \
+ --crate "$CRATE" \
+ --output "rendered/${CRATE}.rb"
+ echo "--- Rendered formula ---"
+ cat "rendered/${CRATE}.rb"
+
+ - name: Generate moq-bot token
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
+ id: token
+ with:
+ app-id: ${{ secrets.APP_ID }}
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
+ owner: moq-dev
+ repositories: homebrew-tap
+ permission-contents: write
+
+ - name: Checkout homebrew-tap
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ repository: moq-dev/homebrew-tap
+ token: ${{ steps.token.outputs.token }}
+ path: homebrew-tap
+
+ - name: Commit and push formula
+ env:
+ CRATE: ${{ steps.parse.outputs.crate }}
+ VERSION: ${{ steps.parse.outputs.version }}
+ GIT_AUTHOR_NAME: moq-bot
+ GIT_AUTHOR_EMAIL: moq-bot[bot]@users.noreply.github.com
+ GIT_COMMITTER_NAME: moq-bot
+ GIT_COMMITTER_EMAIL: moq-bot[bot]@users.noreply.github.com
+ run: |
+ cd homebrew-tap
+ mkdir -p Formula
+ cp "../rendered/${CRATE}.rb" "Formula/${CRATE}.rb"
+
+ # Seed a README the first time the tap repo receives a push.
+ if [[ ! -f README.md ]]; then
+ cat > README.md <<'EOF'
+ # moq Homebrew tap
+
+ Homebrew formulae for the [moq](https://github.com/moq-dev/moq) workspace.
+
+ ```bash
+ brew tap moq-dev/tap
+ brew install moq-dev/tap/moq-relay
+ brew install moq-dev/tap/moq-cli
+ brew install moq-dev/tap/moq-token-cli
+ brew install moq-dev/tap/moq-gst
+ ```
+
+ Formulae here are generated automatically by the
+ [release-brew workflow](https://github.com/moq-dev/moq/blob/main/.github/workflows/release-brew.yml)
+ in moq-dev/moq. Do not edit by hand; edit the templates under
+ `.github/homebrew/Formula/` in moq-dev/moq instead.
+ EOF
+ fi
+
+ if git diff --quiet && [[ -z "$(git status --porcelain)" ]]; then
+ echo "No changes to commit (formula already up to date)."
+ exit 0
+ fi
+
+ git add Formula/ README.md
+ git commit -m "${CRATE}: bump to v${VERSION}"
+ git push
diff --git a/.github/workflows/release-go.yml b/.github/workflows/release-go.yml
new file mode 100644
index 0000000000..a22d4552b2
--- /dev/null
+++ b/.github/workflows/release-go.yml
@@ -0,0 +1,266 @@
+name: Release Go
+
+on:
+ push:
+ tags:
+ - "moq-ffi-v*"
+ pull_request:
+ paths:
+ - ".github/workflows/release-go.yml"
+ - ".github/scripts/release.sh"
+ - "go/scripts/package.sh"
+ - "go/scripts/publish.sh"
+ - "rs/moq-ffi/build.sh"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: release-go
+ cancel-in-progress: false
+
+env:
+ UNIFFI_BINDGEN_GO_TAG: v0.7.1+v0.31.0
+
+jobs:
+ parse-version:
+ name: Parse version
+ runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.parse.outputs.version }}
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Parse version
+ id: parse
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ run: |
+ if [[ "$EVENT_NAME" == "pull_request" ]]; then
+ CARGO_VERSION=$(grep '^version' rs/moq-ffi/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
+ echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT"
+ echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION"
+ else
+ .github/scripts/release.sh parse-version moq-ffi
+ fi
+
+ build:
+ name: Build moq-ffi (${{ matrix.target }})
+ needs: [parse-version]
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-unknown-linux-gnu
+ os: ubuntu-latest
+ - target: aarch64-unknown-linux-gnu
+ os: ubuntu-latest
+ - target: x86_64-apple-darwin
+ os: macos-latest
+ - target: aarch64-apple-darwin
+ os: macos-latest
+ - target: x86_64-pc-windows-msvc
+ os: windows-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Rust cache
+ uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
+
+ - name: Install cross-compilation tools (Linux ARM64)
+ if: matrix.target == 'aarch64-unknown-linux-gnu'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y gcc-aarch64-linux-gnu
+
+ - name: Build
+ shell: bash
+ env:
+ BUILD_TARGET: ${{ matrix.target }}
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ run: |
+ ./rs/moq-ffi/build.sh \
+ --target "$BUILD_TARGET" \
+ --version "$BUILD_VERSION" \
+ --output dist
+
+ - name: Upload lib
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: go-lib-${{ matrix.target }}
+ # package.sh expects /; upload just lib/ contents.
+ path: dist/moq-ffi-${{ needs.parse-version.outputs.version }}-${{ matrix.target }}/lib/
+
+ bindings:
+ name: Generate bindings
+ needs: [parse-version]
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+
+ - name: Rust cache
+ uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
+
+ - name: Install uniffi-bindgen-go
+ run: |
+ cargo install uniffi-bindgen-go \
+ --git https://github.com/NordSecurity/uniffi-bindgen-go \
+ --tag "$UNIFFI_BINDGEN_GO_TAG"
+
+ - name: Generate bindings
+ env:
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ run: |
+ ./rs/moq-ffi/build.sh \
+ --bindings-only \
+ --version "$BUILD_VERSION" \
+ --output dist
+
+ - name: Upload bindings
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: go-bindings
+ path: dist/bindings/go/
+
+ package:
+ name: Package
+ needs: [parse-version, build, bindings]
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Download per-target libs
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ path: libs-raw
+ pattern: go-lib-*
+
+ - name: Flatten lib layout
+ run: |
+ mkdir -p libs
+ for dir in libs-raw/go-lib-*; do
+ target="${dir#libs-raw/go-lib-}"
+ mv "$dir" "libs/$target"
+ done
+ ls -la libs
+
+ - name: Download bindings
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: go-bindings
+ path: bindings-raw
+
+ - name: Flatten bindings layout
+ # uniffi-bindgen-go emits moq/{moq.go,moq.h} (some versions nest it
+ # under uniffi/). package.sh expects bindings/moq/. Copy the whole
+ # dir so the generated C header (moq.h, #include'd by moq.go's cgo
+ # preamble) travels with it.
+ run: |
+ if [[ -d bindings-raw/uniffi/moq ]]; then
+ cp -R bindings-raw/uniffi/moq bindings-moq
+ elif [[ -d bindings-raw/moq ]]; then
+ cp -R bindings-raw/moq bindings-moq
+ else
+ echo "Error: could not locate moq bindings dir under bindings-raw/" >&2
+ find bindings-raw -type f
+ exit 1
+ fi
+ mkdir -p bindings
+ mv bindings-moq bindings/moq
+
+ - name: Package
+ env:
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ run: |
+ ./go/scripts/package.sh \
+ --version "$BUILD_VERSION" \
+ --lib-dir libs \
+ --bindings-dir bindings \
+ --output release-out
+
+ - name: Upload Go module
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: go-package
+ path: release-out/*
+
+ publish:
+ name: Publish to Go module mirror
+ needs: [package, parse-version]
+ runs-on: ubuntu-latest
+ if: github.event_name == 'push'
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Generate moq-bot token
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
+ id: token
+ with:
+ app-id: ${{ secrets.APP_ID }}
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
+ owner: moq-dev
+ repositories: moq-go
+ # Narrow the minted token to only what publish.sh needs.
+ permission-contents: write
+
+ - name: Download package
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: go-package
+ path: go-out
+
+ - name: Publish to moq-dev/moq-go mirror
+ env:
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ # Token is minted fresh per run, expires in 1 hour, never at rest.
+ GO_MIRROR_TOKEN: ${{ steps.token.outputs.token }}
+ GIT_AUTHOR_NAME: moq-bot
+ GIT_AUTHOR_EMAIL: moq-bot[bot]@users.noreply.github.com
+ run: ./go/scripts/publish.sh
+
+ publish-dry-run:
+ name: Publish dry-run
+ needs: [package, parse-version]
+ runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request'
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Download package
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: go-package
+ path: go-out
+
+ - name: Dry-run publish to mirror
+ env:
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ run: ./go/scripts/publish.sh --dry-run
diff --git a/.github/workflows/release-js.yml b/.github/workflows/release-js.yml
new file mode 100644
index 0000000000..c13e20b0ff
--- /dev/null
+++ b/.github/workflows/release-js.yml
@@ -0,0 +1,49 @@
+name: Release JS
+
+permissions:
+ contents: read
+ id-token: write
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'js/**'
+ - '.github/workflows/release-js.yml'
+ pull_request:
+ paths:
+ - '.github/workflows/release-js.yml'
+ - 'js/common/release.ts'
+
+concurrency:
+ group: release-js
+ cancel-in-progress: false
+
+jobs:
+ release:
+ name: Release JS Packages
+ runs-on: ubuntu-latest
+ if: github.repository_owner == 'moq-dev'
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main
+ with:
+ determinate: false
+ - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main
+
+ - name: Setup npm registry
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+ with:
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Install dependencies
+ run: nix develop --command bun install --frozen-lockfile
+
+ - name: Release packages
+ env:
+ DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}
+ run: nix develop --command bun --filter '*' release
diff --git a/.github/workflows/release-kt.yml b/.github/workflows/release-kt.yml
new file mode 100644
index 0000000000..39895ce8f8
--- /dev/null
+++ b/.github/workflows/release-kt.yml
@@ -0,0 +1,226 @@
+name: Release Kotlin
+
+on:
+ push:
+ tags:
+ - "moq-ffi-v*"
+ pull_request:
+ paths:
+ - ".github/workflows/release-kt.yml"
+ - ".github/scripts/release.sh"
+ - "kt/**"
+ - "rs/moq-ffi/build.sh"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: release-kt
+ cancel-in-progress: false
+
+jobs:
+ parse-version:
+ name: Parse version
+ runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.parse.outputs.version }}
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Parse version
+ id: parse
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ run: |
+ if [[ "$EVENT_NAME" == "pull_request" ]]; then
+ CARGO_VERSION=$(grep '^version' rs/moq-ffi/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
+ echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT"
+ echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION"
+ else
+ .github/scripts/release.sh parse-version moq-ffi
+ fi
+
+ build:
+ name: Build moq-ffi (${{ matrix.target }})
+ needs: [parse-version]
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # Android
+ - target: aarch64-linux-android
+ os: ubuntu-latest
+ - target: armv7-linux-androideabi
+ os: ubuntu-latest
+ - target: x86_64-linux-android
+ os: ubuntu-latest
+ # Desktop JVM
+ - target: x86_64-unknown-linux-gnu
+ os: ubuntu-latest
+ - target: aarch64-unknown-linux-gnu
+ os: ubuntu-latest
+ - target: universal-apple-darwin
+ os: macos-latest
+ - target: x86_64-pc-windows-msvc
+ os: windows-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ with:
+ targets: ${{ matrix.target == 'universal-apple-darwin' && 'x86_64-apple-darwin,aarch64-apple-darwin' || matrix.target }}
+
+ - name: Rust cache
+ uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
+
+ - name: Install cross-compilation tools (Linux ARM64)
+ if: matrix.target == 'aarch64-unknown-linux-gnu'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y gcc-aarch64-linux-gnu
+
+ - name: Install cargo-ndk (Android)
+ if: contains(matrix.target, 'android')
+ run: cargo install cargo-ndk
+
+ - name: Build
+ shell: bash
+ env:
+ BUILD_TARGET: ${{ matrix.target }}
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ run: |
+ ./rs/moq-ffi/build.sh \
+ --target "$BUILD_TARGET" \
+ --version "$BUILD_VERSION" \
+ --output dist
+
+ - name: Upload lib
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: kotlin-lib-${{ matrix.target }}
+ # Upload only the lib/ contents; package.sh expects /.
+ path: dist/moq-ffi-${{ needs.parse-version.outputs.version }}-${{ matrix.target }}/lib/
+
+ bindings:
+ name: Generate bindings
+ needs: [parse-version]
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+
+ - name: Rust cache
+ uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
+
+ - name: Generate bindings
+ env:
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ run: |
+ ./rs/moq-ffi/build.sh \
+ --bindings-only \
+ --version "$BUILD_VERSION" \
+ --output dist
+
+ - name: Upload bindings
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: kotlin-bindings
+ # Upload only the kotlin subdir.
+ path: dist/bindings/kotlin/
+
+ package:
+ name: Package and publish
+ needs: [parse-version, build, bindings]
+ runs-on: ubuntu-latest
+ env:
+ # Picked up by com.vanniktech.maven.publish via Gradle project properties.
+ ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
+ ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
+ ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }}
+ ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ with:
+ distribution: temurin
+ java-version: "17"
+
+ - name: Set up Android SDK
+ uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
+
+ - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+
+ - name: Download per-target libs
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ path: libs-raw
+ pattern: kotlin-lib-*
+
+ - name: Flatten lib layout
+ run: |
+ mkdir -p libs
+ for dir in libs-raw/kotlin-lib-*; do
+ target="${dir#libs-raw/kotlin-lib-}"
+ mv "$dir" "libs/$target"
+ done
+ ls -la libs
+
+ - name: Download bindings
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: kotlin-bindings
+ path: bindings
+
+ - name: Stage native libs + bindings into the gradle module
+ env:
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ run: |
+ ./kt/scripts/package.sh \
+ --version "$BUILD_VERSION" \
+ --lib-dir libs \
+ --bindings-dir bindings \
+ --output release-out
+
+ - name: Publish to Maven Central
+ if: github.event_name == 'push'
+ env:
+ MOQFFI_VERSION: ${{ needs.parse-version.outputs.version }}
+ run: |
+ gradle -p kt \
+ -Pmoqffi.version="$MOQFFI_VERSION" \
+ -Pandroid.enabled=true \
+ :moq:publishAndReleaseToMavenCentral --no-configuration-cache
+
+ - name: Publish to Maven Local (PR dry-run)
+ if: github.event_name == 'pull_request'
+ env:
+ MOQFFI_VERSION: ${{ needs.parse-version.outputs.version }}
+ run: |
+ gradle -p kt \
+ -Pmoqffi.version="$MOQFFI_VERSION" \
+ -Pandroid.enabled=true \
+ :moq:publishToMavenLocal --no-configuration-cache
+
+ - name: Upload local maven layout
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: kotlin-package
+ path: release-out/*
diff --git a/.github/workflows/release-py-ffi.yml b/.github/workflows/release-py-ffi.yml
new file mode 100644
index 0000000000..a9f8915639
--- /dev/null
+++ b/.github/workflows/release-py-ffi.yml
@@ -0,0 +1,165 @@
+name: Release Py FFI
+
+# Builds the `moq-ffi` wheel (raw uniffi bindings). Driven by the
+# `moq-ffi-v*` tag that release-plz pushes when it bumps rs/moq-ffi/Cargo.toml.
+# The wheel inherits its version from that same Cargo.toml (via maturin's
+# dynamic version), so the moq-ffi package stays lockstep with the moq-ffi
+# crate. The ergonomic `moq-rs` wrapper is released separately (release-py.yml).
+
+on:
+ push:
+ tags:
+ - "moq-ffi-v*"
+ pull_request:
+ paths:
+ - ".github/workflows/release-py-ffi.yml"
+ - ".github/scripts/release.sh"
+
+permissions:
+ contents: read
+
+# Scope by event + ref so a PR dry-run can't queue ahead of a tag publish.
+concurrency:
+ group: release-py-ffi-${{ github.event_name }}-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ parse-version:
+ name: Parse version
+ runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.parse.outputs.version }}
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Parse version
+ id: parse
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ run: |
+ if [[ "$EVENT_NAME" == "pull_request" ]]; then
+ CARGO_VERSION=$(grep '^version' rs/moq-ffi/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
+ echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT"
+ echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION"
+ else
+ .github/scripts/release.sh parse-version moq-ffi
+ fi
+
+ # Maturin reads the wheel version from rs/moq-ffi/Cargo.toml (via
+ # `dynamic = ["version"]` in pyproject.toml). If a tag is pushed by
+ # hand without bumping Cargo.toml first, the wheel would ship with
+ # the previous version and PyPI would reject the duplicate upload.
+ - name: Verify Cargo.toml matches tag
+ if: github.event_name == 'push'
+ env:
+ TAG_VERSION: ${{ steps.parse.outputs.version }}
+ run: |
+ CARGO_VERSION=$(grep '^version' rs/moq-ffi/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
+ if [[ "$TAG_VERSION" != "$CARGO_VERSION" ]]; then
+ echo "::error::Tag version ($TAG_VERSION) does not match rs/moq-ffi/Cargo.toml version ($CARGO_VERSION). Bump Cargo.toml before tagging."
+ exit 1
+ fi
+ echo "Cargo.toml version matches tag: $TAG_VERSION"
+
+ build:
+ name: Build wheels (${{ matrix.target }})
+ needs: [parse-version]
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-unknown-linux-gnu
+ os: ubuntu-latest
+ - target: aarch64-unknown-linux-gnu
+ os: ubuntu-latest
+ # Use manylinux_2_28 for aarch64-linux to get a newer GCC.
+ # manylinux2014 ships GCC 4.8.5 which can't compile aws-lc-sys.
+ manylinux: 2_28
+ # The cross image sets TARGET_CC/CXX which leaks into host builds.
+ before-script-linux: "unset TARGET_CC TARGET_CXX"
+ - target: x86_64-apple-darwin
+ os: macos-15-intel
+ - target: aarch64-apple-darwin
+ os: macos-latest
+ - target: x86_64-pc-windows-msvc
+ os: windows-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
+ with:
+ python-version: "3.12"
+
+ - name: Build wheels
+ uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1
+ with:
+ # py/moq-ffi/pyproject.toml drives the build; its [tool.maturin]
+ # block points at rs/moq-ffi/Cargo.toml.
+ working-directory: py/moq-ffi
+ args: --release --out dist
+ target: ${{ matrix.target }}
+ manylinux: ${{ matrix.manylinux || 'auto' }}
+ before-script-linux: ${{ matrix.before-script-linux || '' }}
+
+ - name: Upload wheels
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: python-${{ matrix.target }}
+ path: py/moq-ffi/dist/*.whl
+
+ sdist:
+ name: Build sdist
+ needs: [parse-version]
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
+ with:
+ python-version: "3.12"
+
+ - name: Build sdist
+ uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1
+ with:
+ working-directory: py/moq-ffi
+ command: sdist
+ args: --out dist
+
+ - name: Upload sdist
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: python-sdist
+ path: py/moq-ffi/dist/*.tar.gz
+
+ publish:
+ name: Publish to PyPI
+ needs: [build, sdist]
+ runs-on: ubuntu-latest
+ if: github.event_name == 'push'
+ environment:
+ name: pypi
+ url: https://pypi.org/p/moq-ffi
+ permissions:
+ id-token: write
+
+ steps:
+ - name: Download wheels
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ pattern: python-*
+ path: dist
+ merge-multiple: true
+
+ - name: Publish to PyPI
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
diff --git a/.github/workflows/release-py.yml b/.github/workflows/release-py.yml
new file mode 100644
index 0000000000..baab2ae62f
--- /dev/null
+++ b/.github/workflows/release-py.yml
@@ -0,0 +1,100 @@
+name: Release Py
+
+# Publishes the pure-python `moq-rs` wrapper wheel (import name `moq`) to PyPI.
+# This is the package most callers install; the raw `moq-ffi` bindings it
+# depends on are built separately (release-py-ffi.yml).
+#
+# Versioned independently of the moq-ffi crate: bump `version` in
+# py/moq-rs/pyproject.toml by hand in a normal PR. On merge to main this reads
+# that version and publishes only if it isn't already on PyPI. That registry
+# check is the release gate (the same model release-plz uses for crates), so
+# there's no separate tag to remember. The wrapper depends on `moq-ffi ~= 0.2.x`
+# and floats to the latest compatible moq-ffi patch, so a moq-ffi patch needs no
+# wrapper re-release.
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - "py/moq-rs/**"
+ - ".github/workflows/release-py.yml"
+ - ".github/scripts/release.sh"
+ pull_request:
+ paths:
+ - "py/moq-rs/**"
+ - ".github/workflows/release-py.yml"
+ - ".github/scripts/release.sh"
+
+permissions:
+ contents: read
+
+# Separate PR builds from mainline publishes so a PR run can't queue ahead of
+# (and delay) a release on main.
+concurrency:
+ group: release-py-${{ github.event_name }}-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: Build wheel + sdist
+ runs-on: ubuntu-latest
+ if: ${{ github.repository_owner == 'moq-dev' }}
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ # On PRs we always "publish" into the dry-run sense (build only). On main
+ # we publish only when the version isn't already on PyPI.
+ publish: ${{ github.event_name == 'push' && steps.pypi.outputs.exists == 'false' }}
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Read version
+ id: version
+ run: .github/scripts/release.sh read-version py/moq-rs/pyproject.toml
+
+ # The release gate: skip publishing a version that's already on PyPI
+ # (re-uploads are rejected anyway, but this keeps the run green and makes
+ # the no-op explicit). Runs on PRs too, as an informational dry-run.
+ - name: Check PyPI
+ id: pypi
+ env:
+ VERSION: ${{ steps.version.outputs.version }}
+ run: .github/scripts/release.sh pypi-exists moq-rs "$VERSION"
+
+ - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main
+ with:
+ determinate: false
+ - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main
+
+ - name: Build
+ run: nix develop --command just py package
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: python-wrapper
+ path: py/dist/*
+
+ publish:
+ name: Publish to PyPI
+ needs: [build]
+ runs-on: ubuntu-latest
+ if: ${{ needs.build.outputs.publish == 'true' }}
+ environment:
+ name: pypi
+ url: https://pypi.org/p/moq-rs
+ permissions:
+ id-token: write
+
+ steps:
+ - name: Download artifacts
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: python-wrapper
+ path: dist
+
+ - name: Publish to PyPI
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
diff --git a/.github/workflows/release-rs.yml b/.github/workflows/release-rs.yml
new file mode 100644
index 0000000000..d60a2db114
--- /dev/null
+++ b/.github/workflows/release-rs.yml
@@ -0,0 +1,50 @@
+name: Release RS
+
+permissions:
+ pull-requests: write
+ contents: write
+
+# Only one release job can run at a time.
+concurrency:
+ group: release-rs
+ cancel-in-progress: false
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ release:
+ name: Plz
+
+ runs-on: ubuntu-latest
+ if: ${{ github.repository_owner == 'moq-dev' }}
+
+ steps:
+ # Generating a GitHub token, so that PRs and tags created by
+ # the release-plz-action can trigger actions workflows.
+ - name: Generate GitHub token
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
+ id: generate-token
+ with:
+ app-id: ${{ secrets.APP_ID }}
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
+
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+ token: ${{ steps.generate-token.outputs.token }}
+
+ # Install Nix for system dependencies (e.g. ffmpeg)
+ - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main
+ with:
+ determinate: false
+ - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main
+
+ - name: Release
+ run: nix develop --command just rs release
+ env:
+ GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
diff --git a/.github/workflows/release-swift.yml b/.github/workflows/release-swift.yml
new file mode 100644
index 0000000000..a0ede8712f
--- /dev/null
+++ b/.github/workflows/release-swift.yml
@@ -0,0 +1,302 @@
+name: Release Swift
+
+on:
+ push:
+ tags:
+ - "moq-ffi-v*"
+ pull_request:
+ paths:
+ - ".github/workflows/release-swift.yml"
+ - ".github/scripts/release.sh"
+ - "swift/Package.swift.template"
+ - "swift/scripts/package.sh"
+ - "swift/scripts/publish.sh"
+ - "swift/scripts/verify.sh"
+ - "rs/moq-ffi/build.sh"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: release-swift
+ cancel-in-progress: false
+
+jobs:
+ parse-version:
+ name: Parse version
+ runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.parse.outputs.version }}
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Parse version
+ id: parse
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ run: |
+ if [[ "$EVENT_NAME" == "pull_request" ]]; then
+ CARGO_VERSION=$(grep '^version' rs/moq-ffi/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
+ echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT"
+ echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION"
+ else
+ .github/scripts/release.sh parse-version moq-ffi
+ fi
+
+ build:
+ name: Build moq-ffi (${{ matrix.target }})
+ needs: [parse-version]
+ runs-on: macos-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ target:
+ - aarch64-apple-ios
+ - aarch64-apple-ios-sim
+ # x86_64-apple-ios is the Intel iOS simulator slice (Apple never
+ # shipped Intel iOS devices), still needed for Xcode users on
+ # Intel Macs running the simulator.
+ - x86_64-apple-ios
+ - universal-apple-darwin
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ with:
+ targets: ${{ matrix.target == 'universal-apple-darwin' && 'x86_64-apple-darwin,aarch64-apple-darwin' || matrix.target }}
+
+ - name: Rust cache
+ uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
+
+ - name: Build
+ shell: bash
+ env:
+ BUILD_TARGET: ${{ matrix.target }}
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ # Set iOS deployment target to avoid ___chkstk_darwin linker errors.
+ IPHONEOS_DEPLOYMENT_TARGET: ${{ contains(matrix.target, 'apple-ios') && '16.0' || '' }}
+ run: |
+ ./rs/moq-ffi/build.sh \
+ --target "$BUILD_TARGET" \
+ --version "$BUILD_VERSION" \
+ --output dist
+
+ - name: Upload lib
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: swift-lib-${{ matrix.target }}
+ path: dist/moq-ffi-${{ needs.parse-version.outputs.version }}-${{ matrix.target }}/lib/
+
+ bindings:
+ name: Generate bindings
+ needs: [parse-version]
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+
+ - name: Rust cache
+ uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
+
+ - name: Generate bindings
+ env:
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ run: |
+ ./rs/moq-ffi/build.sh \
+ --bindings-only \
+ --version "$BUILD_VERSION" \
+ --output dist
+
+ - name: Upload bindings
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: swift-bindings
+ path: dist/bindings/swift/
+
+ package:
+ name: Package
+ needs: [parse-version, build, bindings]
+ runs-on: macos-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Download per-target libs
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ path: libs-raw
+ pattern: swift-lib-*
+
+ - name: Flatten lib layout
+ run: |
+ mkdir -p libs
+ for dir in libs-raw/swift-lib-*; do
+ target="${dir#libs-raw/swift-lib-}"
+ mv "$dir" "libs/$target"
+ done
+ ls -la libs
+
+ - name: Download bindings
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: swift-bindings
+ path: bindings
+
+ - name: Package
+ env:
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ run: |
+ ./swift/scripts/package.sh \
+ --version "$BUILD_VERSION" \
+ --lib-dir libs \
+ --bindings-dir bindings \
+ --output release-out
+
+ - name: Upload Swift package
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: swift-package
+ path: release-out/*
+
+ release:
+ # Attach MoqFFI.xcframework.zip (and the staged Swift package
+ # tarball) to the moq-ffi-v* GitHub release. The mirror's
+ # Package.swift binaryTarget URL points at this asset, so it must
+ # exist before any consumer resolves the SPM tag.
+ name: Attach assets to GitHub release
+ needs: [package, parse-version]
+ runs-on: ubuntu-latest
+ if: github.event_name == 'push'
+ permissions:
+ contents: write
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+ # No git operations here use the stored credential. gh CLI
+ # below picks up GH_TOKEN directly, so don't persist a
+ # contents-write token in .git/config.
+ persist-credentials: false
+
+ - name: Find previous tag
+ id: prev_tag
+ run: .github/scripts/release.sh prev-tag moq-ffi
+
+ - name: Download package
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: swift-package
+ path: artifacts
+
+ - name: Create or update release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ github.ref_name }}
+ RELEASE_TITLE: "moq-ffi v${{ needs.parse-version.outputs.version }}"
+ RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }}
+ run: .github/scripts/release.sh create artifacts
+
+ verify:
+ # Gate the mirror push on actually being able to resolve the staged
+ # package against the live release asset. Catches manifests that
+ # look syntactically fine but SPM cannot resolve (e.g. a path-based
+ # binaryTarget slipping in where a URL+checksum is expected).
+ name: Verify staged package resolves
+ needs: [release, parse-version]
+ runs-on: macos-latest
+ if: github.event_name == 'push'
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Download staged package
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: swift-package
+ path: artifacts
+
+ - name: Resolve and build smoke consumer
+ env:
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ run: |
+ ./swift/scripts/verify.sh --tarball "artifacts/moq-ffi-${BUILD_VERSION}-swift.tar.gz"
+
+ publish:
+ name: Publish to Swift Package mirror
+ needs: [verify, parse-version]
+ runs-on: ubuntu-latest
+ if: github.event_name == 'push'
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Generate moq-bot token
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
+ id: token
+ with:
+ app-id: ${{ secrets.APP_ID }}
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
+ owner: moq-dev
+ repositories: moq-swift
+ # Narrow the minted token to only what publish.sh needs,
+ # rather than inheriting the full App installation scope.
+ permission-contents: write
+
+ - name: Download package
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: swift-package
+ path: swift-out
+
+ - name: Publish to moq-dev/moq-swift mirror
+ env:
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ # Token is minted fresh per run, expires in 1 hour, never at rest.
+ SWIFT_MIRROR_TOKEN: ${{ steps.token.outputs.token }}
+ # Use the moq-bot identity for the commit author so the
+ # mirror's git log reflects who actually pushed.
+ GIT_AUTHOR_NAME: moq-bot
+ GIT_AUTHOR_EMAIL: moq-bot[bot]@users.noreply.github.com
+ run: ./swift/scripts/publish.sh
+
+ publish-dry-run:
+ name: Publish dry-run
+ needs: [package, parse-version]
+ runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request'
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Download package
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: swift-package
+ path: swift-out
+
+ - name: Dry-run publish to mirror
+ env:
+ BUILD_VERSION: ${{ needs.parse-version.outputs.version }}
+ run: ./swift/scripts/publish.sh --dry-run
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index a6aef6223d..0000000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,52 +0,0 @@
-name: Release
-
-permissions:
- pull-requests: write
- contents: write
-
-# Only one release job can run at a time.
-concurrency:
- group: release
- cancel-in-progress: true
-
-on:
- push:
- branches:
- - main
-
-jobs:
- release:
- name: Plz
-
- runs-on: ubuntu-latest
- if: ${{ github.repository_owner == 'moq-dev' }}
-
- steps:
- # Generating a GitHub token, so that PRs and tags created by
- # the release-plz-action can trigger actions workflows.
- - name: Generate GitHub token
- uses: actions/create-github-app-token@v2
- id: generate-token
- with:
- # GitHub App ID secret name
- app-id: ${{ secrets.APP_ID }}
- # GitHub App private key secret name
- private-key: ${{ secrets.APP_PRIVATE_KEY }}
-
- # Checkout the repository
- - name: Checkout repository
- uses: actions/checkout@v6
- with:
- fetch-depth: 0
- token: ${{ steps.generate-token.outputs.token }}
-
- # Instal Rust
- - name: Install Rust toolchain
- uses: dtolnay/rust-toolchain@stable
-
- # Run release-plz to create PRs and releases
- - name: Release-plz
- uses: MarcoIeni/release-plz-action@v0.5
- env:
- GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
- CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
diff --git a/.github/workflows/rpm-repo.yml b/.github/workflows/rpm-repo.yml
new file mode 100644
index 0000000000..f4d0dedd05
--- /dev/null
+++ b/.github/workflows/rpm-repo.yml
@@ -0,0 +1,60 @@
+name: rpm-repo
+
+# Mirrors .rpm artifacts attached to a release into the rpm.moq.dev repo.
+# Invoked via workflow_dispatch from each per-binary release workflow's
+# final job (gh workflow run rpm-repo.yml -f tag=...), or manually.
+#
+# Note: we do NOT trigger on release:published because releases created
+# with the default GITHUB_TOKEN don't cascade to other workflows. The
+# per-binary workflows call us explicitly instead.
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Release tag to ingest (e.g. moq-relay-v1.2.3)"
+ required: true
+
+permissions:
+ contents: read
+
+concurrency:
+ group: rpm-repo
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ name: Publish to rpm.moq.dev
+ runs-on: ubuntu-22.04
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+
+ - name: Set up packaging tools
+ uses: ./.github/actions/setup-packaging
+
+ - name: Download .rpm assets from release
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ inputs.tag }}
+ run: |
+ mkdir -p artifacts
+ gh release download "$TAG" \
+ --repo "${{ github.repository }}" \
+ --dir artifacts \
+ --pattern "*.rpm"
+ ls -la artifacts/
+
+ - name: Publish to R2
+ shell: bash
+ env:
+ ARTIFACTS_DIR: artifacts
+ R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
+ R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+ R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
+ SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
+ SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
+ run: ./infra/rpm/publish.sh
diff --git a/.github/workflows/update-flake.yml b/.github/workflows/update-flake.yml
index 285cb362d8..561963c82f 100644
--- a/.github/workflows/update-flake.yml
+++ b/.github/workflows/update-flake.yml
@@ -16,13 +16,15 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Install Nix
- uses: DeterminateSystems/nix-installer-action@main
+ uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main
+ with:
+ determinate: false
- name: Update flake.lock
- uses: DeterminateSystems/update-flake-lock@main
+ uses: DeterminateSystems/update-flake-lock@5ba4a20ae344a5edd7b97ed3002219974f4d20d7 # main
with:
pr-title: "Update flake.lock"
pr-labels: |
diff --git a/.gitignore b/.gitignore
index 6b7eb49e3c..5b4bdd9adb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,7 +30,22 @@ node_modules
*.m3u8
*.m4s
-# We're using bun (and sometimes deno)
+# Except these test fixtures, which are committed.
+!rs/moq-mux/src/container/fmp4/test_data/*.mp4
+
+# Cloudflare Wrangler
+.wrangler/
+
+# We're using bun
package-lock.json
yarn.lock
pnpm-lock.yaml
+
+# Python
+__pycache__/
+*.pyc
+.venv/
+
+# maturin develop drops uniffi bindings + cdylib here during editable
+# installs of the py/moq-ffi package.
+/py/moq-ffi/moq_ffi/_uniffi/
diff --git a/.remarkignore b/.remarkignore
new file mode 100644
index 0000000000..c4a7d97cb1
--- /dev/null
+++ b/.remarkignore
@@ -0,0 +1,8 @@
+node_modules/
+target/
+dist/
+out/
+pkg/
+.direnv/
+.terraform/
+**/CHANGELOG.md
diff --git a/.remarkrc.mjs b/.remarkrc.mjs
new file mode 100644
index 0000000000..29be569e21
--- /dev/null
+++ b/.remarkrc.mjs
@@ -0,0 +1,19 @@
+export default {
+ plugins: [
+ "remark-frontmatter",
+ "remark-preset-lint-consistent",
+ "remark-preset-lint-recommended",
+ ["remark-lint-list-item-indent", "one"],
+ ["remark-lint-maximum-line-length", false],
+ ["remark-lint-no-duplicate-headings", false],
+ ["remark-lint-no-undefined-references", false],
+ ],
+ settings: {
+ bullet: "-",
+ emphasis: "*",
+ strong: "*",
+ listItemIndent: "one",
+ rule: "-",
+ tightDefinitions: true,
+ },
+};
diff --git a/.taplo.toml b/.taplo.toml
new file mode 100644
index 0000000000..2d69dedde8
--- /dev/null
+++ b/.taplo.toml
@@ -0,0 +1,9 @@
+# Skip vendored / build trees so `taplo format` doesn't recurse into them.
+exclude = ["**/node_modules/**", "**/target/**", "**/dist/**", "**/.venv/**"]
+
+# Match the existing repo style (4-space indent on Cargo.toml, lines up
+# to ~120 chars) so the initial format pass stays minimal.
+[formatting]
+indent_string = " "
+column_width = 150
+align_comments = false
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 48fb6b1432..9b4563f0bc 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,4 +1,5 @@
{
"css.lint.unknownAtRules": "ignore",
- "biome.lsp.bin": "./node_modules/.bin/biome"
+ "biome.lsp.bin": "./node_modules/.bin/biome",
+ "cmake.sourceDirectory": "./rs/libmoq"
}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 120000
index 0000000000..681311eb9c
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1 @@
+CLAUDE.md
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index b3c3358d84..a3124624e9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,6 +1,6 @@
# CLAUDE.md
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+This file provides guidance for AI coding agents when working with code in this repository.
## Project Overview
@@ -15,63 +15,207 @@ just fix # Auto-fix linting issues
just build # Build all packages
```
+If `just` is unavailable, use `cargo` or `bun` directly.
+
## Architecture
The project contains multiple layers of protocols:
1. **quic** - Does all the networking.
2. **web-transport** - A small layer on top of QUIC/HTTP3 for browser support. Provided by the browser or the `web-transport` crates.
-3. **moq-lite** - A generic pub/sub protocol on top of `web-transport` implemented by CDNs, splitting content into:
- - broadcast: a collection of tracks produced by a publisher
- - track: a live stream of groups within a broadcast.
- - group: a live stream of frames within a track, each delivered independently over a QUIC stream.
- - frame: a sized payload of bytes.
-4. **hang** - Media-specific encoding/decoding on top of `moq-lite`. Contains:
- - catalog: a JSON track containing a description of other tracks and their properties (for WebCodecs).
- - container: each frame consists of a timestamp and codec bitstream
-5. **application** - Users building on top of `moq-lite` or `hang`
+3. **moq-net** - The networking layer on top of `web-transport`, implemented by CDNs. At session setup it negotiates one of two wire protocols: the simplified `moq-lite` protocol (the layer name) or the full IETF `moq-transport` protocol. Content splits into:
+ - broadcast: a collection of tracks produced by a publisher
+ - track: a live stream of groups within a broadcast.
+ - group: a live stream of frames within a track, each delivered independently over a QUIC stream.
+ - frame: a sized payload of bytes.
+4. **hang** - Media-specific encoding/decoding on top of `moq-net`. Contains:
+ - catalog: a JSON track containing a description of other tracks and their properties (for WebCodecs).
+ - container: each frame consists of a timestamp and codec bitstream
+ - watch/publish: dedicated packages for subscribing/publishing with optional UI overlays
+5. **moq-audio** - Native Opus encode/decode for raw PCM (more codecs to come). Used by `moq-ffi`/`libmoq` so native callers don't have to bring their own codec.
+6. **application** - Users building on top of `moq-net` or `hang`
Key architectural rule: The CDN/relay does not know anything about media. Anything in the `moq` layer should be generic, using rules on the wire on how to deliver content.
-
## Project Structure
```
-/rs/ # Rust crates
- moq/ # Core protocol (published as moq-lite)
- moq-relay/ # Clusterable relay server
- moq-token/ # JWT authentication
- hang/ # Media encoding/streaming
- hang-cli/ # CLI tool for media operations (binary is named `hang`)
-
-/js/ # TypeScript/JavaScript packages
- moq/ # Core protocol for browsers (published as @moq/lite)
- hang/ # Media layer with Web Components (published as @moq/hang)
- hang-demo/ # Demo applications
+/rs/ # Rust crates
+ moq-net/ # Core networking layer (published as moq-net; negotiates moq-lite or moq-transport)
+ moq-native/ # QUIC/WebTransport connection helpers for native apps; clock example lives in examples/clock.rs
+ moq-relay/ # Clusterable relay server (binary: moq-relay)
+ moq-token/ # JWT authentication library
+ moq-token-cli/ # JWT token CLI tool (binary: moq-token-cli)
+ moq-cli/ # CLI tool for media operations (binary: moq)
+ moq-mux/ # Media muxers/demuxers (fMP4, CMAF, HLS)
+ moq-audio/ # Native PCM ↔ Opus encode/decode on top of moq-mux
+ hang/ # Media encoding/streaming (catalog/container format)
+ libmoq/ # C bindings (staticlib)
+ moq-ffi/ # UniFFI bindings for Python/Swift/Kotlin (cdylib + staticlib)
+ moq-boy/ # MoQ Boy emulator publisher (binary: moq-boy)
+ moq-gst/ # GStreamer plugin (moqsink/moqsrc elements)
+
+/js/ # TypeScript/JavaScript packages
+ net/ # Core networking layer for browsers (published as @moq/net)
+ signals/ # Reactive signals library (published as @moq/signals)
+ token/ # JWT token generation (published as @moq/token)
+ clock/ # Clock example (published as @moq/clock)
+ hang/ # Core media layer: catalog, container, support (published as @moq/hang)
+ watch/ # Watch/subscribe to streams + UI (published as @moq/watch)
+ publish/ # Publish media to streams + UI (published as @moq/publish)
+ moq-boy/ # MoQ Boy web viewer (published as @moq/boy)
+
+/py/ # Python packages (uv workspace)
+ moq-ffi/ # Maturin project: rs/moq-ffi cdylib + uniffi bindings.
+ # Distribution `moq-ffi` (PyPI); import `moq_ffi`. One
+ # wheel covers every crate exposed via moq-ffi because
+ # uniffi-linked libraries can't be split across separate
+ # wheels. Version tracks rs/moq-ffi (release-py-ffi.yml
+ # fires on moq-ffi-v* tags). Most callers want `moq-rs`.
+ moq-rs/ # Pure-python ergonomic wrapper. Distribution `moq-rs`
+ # (PyPI, since `moq` is taken); import `moq`. Depends on
+ # moq-ffi via a compatible-release pin (~=0.2.x) so it
+ # floats to the latest moq-ffi patch. Versioned
+ # independently: bump py/moq-rs/pyproject.toml by hand; on
+ # merge to main release-py.yml publishes to PyPI if that
+ # version isn't already there (registry is the gate).
+
+/swift/ # Swift wrapper over rs/moq-ffi (SwiftPM)
+/kt/ # Kotlin wrapper over rs/moq-ffi (Gradle, KMP)
+/go/ # Go wrapper over rs/moq-ffi (uniffi-bindgen-go)
+ # swift/kt/go are in-tree source skeletons.
+ # CI mirrors them to moq-dev/moq-{swift,kotlin,go}
+ # on each moq-ffi-v* tag.
+
+/demo/ # Demos and test media
+ boy/ # MoQ Boy demo (ROM hosting, orchestration justfile)
+ relay/ # Relay server configs (relay.toml, root.toml, leaf*.toml)
+ pub/ # Media hosting (vid.moq.dev)
+ web/ # Web demo (watch/publish examples)
+ throttle/ # Network throttle script for testing
+
+/doc/ # Documentation site (VitePress, deployed via Cloudflare)
```
+## Dependencies
+
+- When adding new dependencies, always use the **newest stable version** available.
+
## Development Tips
1. The project uses `just` as the task runner - check `justfile` for all available commands
-2. For Rust development, the workspace is configured in the `rs/Cargo.toml`
-3. For JS/TS development, bun workspaces are used with configuration in `js/package.json`
+2. For Rust development, the workspace is configured in the root `Cargo.toml`
+3. For JS/TS development, bun workspaces are used with configuration in the root `package.json`
+4. Consult `doc/` for documentation and the [IETF datatracker](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/) for specification drafts when working on protocol-level code
+
+## Version Matching Convention
+
+When matching on `Version` enums, default to the **newest** draft behavior so future versions default forward. Explicitly list older versions:
+
+```rust
+// CORRECT: future versions get draft-17+ behavior
+match version {
+ Version::Draft14 | Version::Draft15 | Version::Draft16 => { /* old behavior */ }
+ _ => { /* newest/draft-17 behavior */ }
+}
+```
+
+## Writing Style
+
+- **No em dashes (—)** in code, comments, doc comments, commit messages, or any prose. Use a period and start a new sentence, or use a comma/parenthesis if the clauses are tightly bound.
+
+## Rust Conventions
+
+- **Error handling**: Use `thiserror` with `#[from]` for library crates, `anyhow` for binaries. Always add `#[non_exhaustive]` to public `thiserror` enums.
+- Use `anyhow::Context` (`.context("msg")`) instead of `.map_err(|_| anyhow::anyhow!("msg"))` for error conversion
+- **Config flags + TOML merge**: For any `#[arg]` field on a TOML-loadable config, use `Option` (not bare `bool` / `String` / etc.). The TOML→CLI merge clobbers bare fields with their `Default` when the flag is absent, silently overwriting TOML values. See `rs/moq-relay/src/config.rs::tests` for the regression test; add one for any new flag.
+- **Prefer `if let` / `let ... else` over an unwrapping `match`**: a `match` whose only job is to unwrap (`Ok(v) => v` / `Some(v) => v`) reads cleaner as `if let Some(v) = x { ... }` or `let Some(v) = x else { ... };`. Matching on an `Option`/`Result` just to bind the inner value is the tell. Keep `match` when both arms do real work or you need the `Err` / `None` payload.
+
+## Comment Conventions
+
+- Keep things brief and avoid comments if the code is self-explanatory. Reserve comments for the non-obvious WHY: a hidden constraint, a subtle invariant, a workaround for a specific bug, behavior that would surprise a reader.
+- Write the way you'd say it out loud, not the way a doc generator would. One short line is almost always enough. Skip throat-clearing like "This function is responsible for...".
+- Comments must reflect the **current** state of the code, not its history. Don't write "X no longer does Y" or "this used to cascade". Describe what the code does today, or delete the comment. Migration context belongs in commit messages and PR descriptions, where it ages with the change rather than rotting in the source.
+
+## AI Attribution
+
+LLM-authored prose visible to humans (PR descriptions, PR comments, review replies) should end with `(Written by Claude)` or similar. Do **not** tag code comments, doc comments, or `/doc` pages: source markers rot. Commit attribution lives in the `Co-Authored-By` trailer, not the commit body.
+
+## Refactor As You Go
+
+A function with 4+ args, or a call site passing the same 3+ values into multiple functions, is a struct waiting to happen. Make the change in the same PR rather than leaving a TODO. Same for repeated tuples returned across modules.
## Tooling
- **TypeScript**: Always use `bun` for all package management and script execution (not npm, yarn, or pnpm)
- **Common**: Use `just` for common development tasks
- **Rust**: Use `cargo` for Rust-specific operations
+- **Formatting/Linting**: Biome for JS/TS formatting and linting
+- **UI**: Plain Web Components in `@moq/watch/ui` and `@moq/publish/ui`, built directly on `@moq/signals`
+- **Builds**: Nix flake for reproducible builds (optional)
+- **Local-first**: When work can live in a `just` recipe (invoked via `nix develop --command`) or as logic in a GitHub Actions workflow step, prefer the recipe. The same code then runs reproducibly on a developer machine and in CI, and is debuggable locally without pushing commits. Workflow YAML should mostly delegate to `just`; reach for plugins (`dorny/paths-filter`, custom actions, etc.) only when a recipe genuinely can't express the logic.
+- **CI**: Prefer building release artifacts inside Nix (`nix build .#pkg`) over relying on runner-provided toolchains and `apt`/`brew` packages. Pinning the build environment in `flake.lock` makes artifacts deterministic and decouples them from drift in GitHub Actions runner images. Reach for the runner-native toolchain only when Nix doesn't fit (e.g. Windows runners).
+- **JS async patterns**: Use `Effect.interval()`, `Effect.timer()`, and `Effect.event()` helpers from `@moq/signals` instead of raw `setInterval`, `setTimeout`, `addEventListener`. These handle cleanup automatically when the Effect is closed.
## Testing Approach
- Run `just check` to execute all tests and linting.
- Run `just fix` to automatically fix formating and easy things.
- Rust tests are integrated within source files
+- Async tests that sleep should call `tokio::time::pause()` at the start to simulate time instantly
+
+## Cross-Package Sync
+
+Changes in one area usually need matching updates elsewhere, including docs. If you skip a row, say why in the PR description.
+
+| Change in | Also update |
+|---|---|
+| `rs/moq-ffi` | `rs/libmoq`, `{py,swift,kt,go}/`, `doc/lib/{py,swift,kt,go,c}` |
+| `rs/moq-net` wire/API | `js/net`, `doc/concept` |
+| `rs/hang` catalog/container | `js/hang`, `doc/concept` |
+| `rs/moq-token` | `js/token` |
+| `rs/moq-relay` config/behavior | `doc/bin/relay/` |
+| `rs/moq-cli` | `doc/bin/cli.md` |
+| `rs/moq-gst` | `doc/bin/gstreamer.md` |
+| `js/{watch,publish}` UI/API | `demo/web` if it consumes the API |
+
+## Branch Targeting
+
+Two long-lived branches:
+
+- **`main`**: stable. Bug fixes, small additive changes, docs, refactors that preserve public/wire behavior.
+- **`dev`**: staging branch for disruptive work. Target it for:
+ - Wire-protocol changes (anything under `rs/moq-net`, including `moq-lite` / `moq-transport` framing or draft bumps).
+ - Breaking changes to public APIs in `rs/moq-ffi`, `rs/libmoq`, `rs/moq-net`, `rs/hang`, `js/net`, `js/hang`, or any of the language wrappers under `swift/`, `kt/`, `go/`, `py/`.
+ - Catalog/container format changes in `rs/hang` or `js/hang`.
+ - Major features that need time to settle before shipping.
+
+`dev` periodically merges into `main` (or vice versa) when the batch is ready to ship. When in doubt, target `main`; reviewers will redirect to `dev` if needed. CI (`pull_request:` workflows) runs on PRs against either branch, so no extra setup is needed when you switch the base.
## Workflow
When making changes to the codebase:
-1. Make your code changes
-2. Run `just fix` to auto-format and fix linting issues
-3. Run `just check` to verify everything passes
-4. Commit and push changes
+
+1. Pick the base branch per [Branch Targeting](#branch-targeting) above
+2. Make your code changes
+3. Run `just fix` to auto-format and fix linting issues
+4. Run `just check` to verify everything passes
+5. Walk the Cross-Package Sync table; update paired packages and docs in the same PR
+6. Add tests where they're easy to write
+7. Commit and push changes
+
+## PR Reviews
+
+CodeRabbit reviews PRs automatically, but it has an hourly quota and runs out of org credits. If a PR shows a "Review limit reached" / "out of usage credits" message instead of an actual review, run the `/review` skill locally against the PR to get review feedback without waiting for the quota to refill.
+
+## PR Title and Description Maintenance
+
+When pushing additional commits to an existing PR, check whether the title and description still describe the change accurately. They often go stale during review iterations: a flag gets renamed, an API gets reshaped, an extra fix lands, etc. The PR description is what shows up in the squash-merge commit, so a stale title/body means a misleading entry in `git log` forever.
+
+Update them with `gh pr edit --title "..." --body "..."` whenever the scope shifts. Specifically watch for:
+
+- Flags, file names, or public APIs renamed in later commits but still referenced by their old name in the PR body.
+- Bullet points in the "Summary" section that describe behavior the latest commits have changed or removed.
+- The test-plan checklist getting out of date as new tests are added.
+
+When you edit a PR description you authored, keep the `(Written by Claude)` marker so reviewers still know the body wasn't human-authored.
diff --git a/Cargo.lock b/Cargo.lock
index 69ff062ba1..429bb8a5e7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -19,13 +19,37 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
-version = "0.6.0-rc.2"
+version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac8202ab55fcbf46ca829833f347a82a2a4ce0596f0304ac322c2d100030cd56"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
- "bytes",
- "crypto-common 0.2.0-rc.4",
- "inout",
+ "crypto-common 0.1.7",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures 0.2.17",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
]
[[package]]
@@ -43,6 +67,12 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+[[package]]
+name = "android_log-sys"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85965b6739a430150bdd138e2374a98af0c3ee0d030b3bb7fc3bddff58d0102e"
+
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -54,9 +84,9 @@ dependencies = [
[[package]]
name = "anstream"
-version = "0.6.21"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
@@ -69,15 +99,15 @@ dependencies = [
[[package]]
name = "anstyle"
-version = "1.0.13"
+version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
-version = "0.2.7"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
@@ -104,18 +134,15 @@ dependencies = [
[[package]]
name = "anyhow"
-version = "1.0.100"
+version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
-dependencies = [
- "backtrace",
-]
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arc-swap"
-version = "1.8.0"
+version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e"
+checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
dependencies = [
"rustversion",
]
@@ -132,11 +159,53 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+[[package]]
+name = "askama"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
+dependencies = [
+ "askama_derive",
+ "itoa",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "askama_derive"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
+dependencies = [
+ "askama_parser",
+ "basic-toml",
+ "memchr",
+ "proc-macro2",
+ "quote",
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "askama_parser"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
+dependencies = [
+ "memchr",
+ "serde",
+ "serde_derive",
+ "winnow 0.7.15",
+]
+
[[package]]
name = "asn1-rs"
-version = "0.7.1"
+version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60"
+checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
@@ -144,7 +213,7 @@ dependencies = [
"nom",
"num-traits",
"rusticata-macros",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"time",
]
@@ -156,7 +225,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"synstructure",
]
@@ -168,45 +237,40 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
-name = "async-channel"
-version = "2.5.0"
+name = "assert-json-diff"
+version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
- "concurrent-queue",
- "event-listener-strategy",
- "futures-core",
- "pin-project-lite",
+ "serde",
+ "serde_json",
]
[[package]]
-name = "async-compat"
-version = "0.2.5"
+name = "async-compression"
+version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590"
+checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
dependencies = [
- "futures-core",
- "futures-io",
- "once_cell",
+ "compression-codecs",
+ "compression-core",
"pin-project-lite",
"tokio",
]
[[package]]
-name = "async-compression"
-version = "0.4.36"
+name = "async-lock"
+version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
- "compression-codecs",
- "compression-core",
- "futures-core",
+ "event-listener",
+ "event-listener-strategy",
"pin-project-lite",
- "tokio",
]
[[package]]
@@ -217,7 +281,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -231,15 +295,6 @@ dependencies = [
"rustc_version",
]
-[[package]]
-name = "atomic-polyfill"
-version = "1.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
-dependencies = [
- "critical-section",
-]
-
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -260,15 +315,15 @@ dependencies = [
[[package]]
name = "autocfg"
-version = "1.5.0"
+version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
-version = "1.15.2"
+version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288"
+checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -277,9 +332,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
-version = "0.35.0"
+version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1"
+checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
dependencies = [
"cc",
"cmake",
@@ -289,9 +344,9 @@ dependencies = [
[[package]]
name = "axum"
-version = "0.8.8"
+version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64 0.22.1",
@@ -313,10 +368,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
- "sha1 0.10.6",
+ "sha1",
"sync_wrapper",
"tokio",
- "tokio-tungstenite 0.28.0",
+ "tokio-tungstenite 0.29.0",
"tower",
"tower-layer",
"tower-service",
@@ -351,7 +406,7 @@ dependencies = [
"arc-swap",
"bytes",
"either",
- "fs-err",
+ "fs-err 3.3.0",
"http",
"http-body",
"hyper",
@@ -402,12 +457,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
-[[package]]
-name = "base32"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
-
[[package]]
name = "base64"
version = "0.21.7"
@@ -422,28 +471,73 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
-version = "1.8.2"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "basic-toml"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bindgen"
+version = "0.72.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
+dependencies = [
+ "bitflags",
+ "cexpr",
+ "clang-sys",
+ "itertools 0.13.0",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash",
+ "shlex 1.3.0",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82"
+checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+dependencies = [
+ "serde",
+]
[[package]]
name = "bitflags"
-version = "2.10.0"
+version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "blake3"
-version = "1.8.3"
+version = "1.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
+checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
- "cpufeatures",
+ "cpufeatures 0.3.0",
]
[[package]]
@@ -457,203 +551,398 @@ dependencies = [
[[package]]
name = "block-buffer"
-version = "0.11.0"
+version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96eb4cdd6cf1b31d671e9efe75c5d1ec614776856cefbe109ca373554a6d514f"
+checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
dependencies = [
"hybrid-array",
- "zeroize",
]
[[package]]
-name = "buf-list"
-version = "1.1.2"
+name = "block2"
+version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6b175f9cf8fffedd4c4b18bcfef092356e952b81f596e148f18e98280994593"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
- "bytes",
+ "objc2",
]
[[package]]
-name = "bumpalo"
-version = "3.19.1"
+name = "boring"
+version = "4.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
+checksum = "3b0e3bc837369e9e662d3845c374ac3771b054d4bc2dee5457591198d16d684c"
+dependencies = [
+ "bitflags",
+ "boring-sys",
+ "foreign-types",
+ "libc",
+ "openssl-macros",
+]
[[package]]
-name = "byteorder"
-version = "1.5.0"
+name = "boring-sys"
+version = "4.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+checksum = "15aad385759d3da2737772aefb391332227bef7cb71fb85c106cadd9d1e63a98"
+dependencies = [
+ "bindgen",
+ "cmake",
+ "fs_extra",
+ "fslock",
+]
[[package]]
-name = "bytes"
-version = "1.11.0"
+name = "boytacean"
+version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
+checksum = "44a774c104e597a24d60ea21e7b949ad19e4aa7a39590dd1467f517066bbed42"
dependencies = [
- "serde",
+ "boytacean-common",
+ "boytacean-encoding",
+ "boytacean-hashing",
+ "built",
+ "chrono",
+ "regex",
]
[[package]]
-name = "bytestring"
-version = "1.5.0"
+name = "boytacean-common"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d51a26a3d429ea3a419c67122eff610fbc96e59d75544f77d677f4e39c094940"
+
+[[package]]
+name = "boytacean-encoding"
+version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289"
+checksum = "e2bfc97163003de6037ab40296d0f5c2e50fe1b76c626e75cedd8f2017346b13"
dependencies = [
- "bytes",
+ "boytacean-common",
+ "boytacean-hashing",
]
[[package]]
-name = "cbindgen"
-version = "0.29.2"
+name = "boytacean-hashing"
+version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "befbfd072a8e81c02f8c507aefce431fe5e7d051f83d48a23ffc9b9fe5a11799"
+checksum = "a4d1f207e18fc7559267427e86a9e45997d242ea6fa10ae41a617053eb5d9eb4"
dependencies = [
- "clap",
- "heck",
- "indexmap 2.13.0",
- "log",
- "proc-macro2",
- "quote",
- "serde",
- "serde_json",
- "syn",
- "tempfile",
- "toml",
+ "boytacean-common",
]
[[package]]
-name = "cc"
-version = "1.2.52"
+name = "bs58"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
- "find-msvc-tools",
- "jobserver",
- "libc",
- "shlex",
+ "tinyvec",
]
[[package]]
-name = "cesu8"
-version = "1.1.0"
+name = "buffer-pool"
+version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+checksum = "a735dacb3d6e895d2537d263a01a09c3e48838e03818a8beed7a41249dffe09f"
+dependencies = [
+ "crossbeam",
+]
[[package]]
-name = "cfg-if"
-version = "1.0.4"
+name = "built"
+version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b"
+dependencies = [
+ "cargo-lock",
+]
[[package]]
-name = "cfg_aliases"
-version = "0.2.1"
+name = "bumpalo"
+version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
-name = "chacha20"
-version = "0.10.0-rc.2"
+name = "byteorder"
+version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9bd162f2b8af3e0639d83f28a637e4e55657b7a74508dba5a9bf4da523d5c9e9"
-dependencies = [
- "cfg-if",
- "cipher",
- "cpufeatures",
- "zeroize",
-]
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
-name = "chrono"
-version = "0.4.42"
+name = "bytes"
+version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
dependencies = [
- "iana-time-zone",
- "js-sys",
- "num-traits",
"serde",
- "wasm-bindgen",
- "windows-link",
]
[[package]]
-name = "cipher"
-version = "0.5.0-rc.1"
+name = "bytestring"
+version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e12a13eb01ded5d32ee9658d94f553a19e804204f2dc811df69ab4d9e0cb8c7"
+checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9"
dependencies = [
- "block-buffer 0.11.0",
- "crypto-common 0.2.0-rc.4",
- "inout",
- "zeroize",
+ "bytes",
]
[[package]]
-name = "clap"
-version = "4.5.54"
+name = "camino"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
+checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
dependencies = [
- "clap_builder",
- "clap_derive",
+ "serde_core",
]
[[package]]
-name = "clap_builder"
-version = "4.5.54"
+name = "cargo-lock"
+version = "10.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
+checksum = "c06acb4f71407ba205a07cb453211e0e6a67b21904e47f6ba1f9589e38f2e454"
dependencies = [
- "anstream",
- "anstyle",
- "clap_lex",
- "strsim",
+ "semver",
+ "serde",
+ "toml 0.8.23",
+ "url",
]
[[package]]
-name = "clap_derive"
-version = "4.5.49"
+name = "cargo-platform"
+version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
+checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
dependencies = [
- "heck",
- "proc-macro2",
- "quote",
- "syn",
+ "serde",
]
[[package]]
-name = "clap_lex"
-version = "0.7.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
-
-[[package]]
-name = "cmake"
-version = "0.1.57"
+name = "cargo_metadata"
+version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
+checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
dependencies = [
- "cc",
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
]
[[package]]
-name = "cobs"
-version = "0.3.0"
+name = "cbindgen"
+version = "0.29.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
+checksum = "c95537b45400390270fae69ac098d057c8f5399001cde9d04f700c105ddfff2d"
dependencies = [
- "thiserror 2.0.17",
+ "clap",
+ "heck",
+ "indexmap 2.14.0",
+ "log",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "syn 2.0.117",
+ "tempfile",
+ "toml 0.9.12+spec-1.1.0",
]
[[package]]
-name = "colorchoice"
+name = "cc"
+version = "1.2.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex 2.0.1",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "cf-rustracing"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6565523d8145e63e0cf1b397a5f1bd4e90d5652a7dffb2de8cec460ff23ef6b1"
+dependencies = [
+ "backtrace",
+ "rand 0.10.1",
+ "tokio",
+ "trackable",
+]
+
+[[package]]
+name = "cf-rustracing-jaeger"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16c0e4d8cce27f6a6eaff58d2b66f063a18b8ed0d6ef0947ae7a263afa3b7c08"
+dependencies = [
+ "cf-rustracing",
+ "hostname",
+ "local-ip-address",
+ "percent-encoding",
+ "rand 0.10.1",
+ "thrift_codec",
+ "tokio",
+ "trackable",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.20.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
+dependencies = [
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "chacha20"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common 0.1.7",
+ "inout",
+]
+
+[[package]]
+name = "clang-sys"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "cmov"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+
+[[package]]
+name = "cobs"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
+dependencies = [
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
@@ -667,9 +956,9 @@ dependencies = [
[[package]]
name = "compression-codecs"
-version = "0.4.35"
+version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2"
+checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
dependencies = [
"compression-core",
"flate2",
@@ -678,9 +967,9 @@ dependencies = [
[[package]]
name = "compression-core"
-version = "0.4.31"
+version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
+checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
[[package]]
name = "concurrent-queue"
@@ -731,6 +1020,18 @@ dependencies = [
"tracing-subscriber",
]
+[[package]]
+name = "const-hex"
+version = "1.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "proptest",
+ "serde_core",
+]
+
[[package]]
name = "const-oid"
version = "0.9.6"
@@ -803,6 +1104,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -818,6 +1128,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+[[package]]
+name = "crossbeam"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-queue",
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
@@ -836,6 +1157,15 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -856,9 +1186,9 @@ dependencies = [
[[package]]
name = "crypto-common"
-version = "0.1.6"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
@@ -866,58 +1196,43 @@ dependencies = [
[[package]]
name = "crypto-common"
-version = "0.2.0-rc.4"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
- "rand_core 0.9.3",
]
[[package]]
-name = "crypto_box"
-version = "0.10.0-pre.0"
+name = "ctr"
+version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2bda4de3e070830cf3a27a394de135b6709aefcc54d1e16f2f029271254a6ed9"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
- "aead",
- "chacha20",
- "crypto_secretbox",
- "curve25519-dalek",
- "salsa20",
- "serdect",
- "subtle",
- "zeroize",
+ "cipher",
]
[[package]]
-name = "crypto_secretbox"
-version = "0.2.0-pre.0"
+name = "ctutils"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "54532aae6546084a52cef855593daf9555945719eeeda9974150e0def854873e"
+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
- "aead",
- "chacha20",
- "cipher",
- "hybrid-array",
- "poly1305",
- "salsa20",
- "subtle",
- "zeroize",
+ "cmov",
]
[[package]]
name = "curve25519-dalek"
-version = "5.0.0-pre.1"
+version = "5.0.0-pre.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6f9200d1d13637f15a6acb71e758f64624048d85b31a5fdbfd8eca1e2687d0b7"
+checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"curve25519-dalek-derive",
- "digest 0.11.0-rc.3",
+ "digest 0.11.3",
"fiat-crypto",
- "rand_core 0.9.3",
+ "rand_core 0.10.1",
"rustc_version",
"serde",
"subtle",
@@ -932,116 +1247,246 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core 0.20.11",
+ "darling_macro 0.20.11",
]
[[package]]
name = "darling"
-version = "0.21.3"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
- "darling_core",
- "darling_macro",
+ "darling_core 0.23.0",
+ "darling_macro 0.23.0",
]
[[package]]
name = "darling_core"
-version = "0.21.3"
+version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
- "syn",
+ "syn 2.0.117",
]
[[package]]
-name = "darling_macro"
-version = "0.21.3"
+name = "darling_core"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
- "darling_core",
+ "ident_case",
+ "proc-macro2",
"quote",
- "syn",
+ "strsim",
+ "syn 2.0.117",
]
[[package]]
-name = "data-encoding"
-version = "2.9.0"
+name = "darling_macro"
+version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core 0.20.11",
+ "quote",
+ "syn 2.0.117",
+]
[[package]]
-name = "der"
-version = "0.7.10"
+name = "darling_macro"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
- "const-oid 0.9.6",
- "pem-rfc7468 0.7.0",
- "zeroize",
+ "darling_core 0.23.0",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
-name = "der"
-version = "0.8.0-rc.10"
+name = "dashmap"
+version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02c1d73e9668ea6b6a28172aa55f3ebec38507131ce179051c8033b5c6037653"
+checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
- "const-oid 0.10.2",
- "pem-rfc7468 1.0.0",
- "zeroize",
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
]
[[package]]
-name = "der-parser"
-version = "10.0.0"
+name = "data-encoding"
+version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
-dependencies = [
- "asn1-rs",
- "displaydoc",
- "nom",
- "num-bigint",
- "num-traits",
- "rusticata-macros",
-]
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
-name = "deranged"
-version = "0.5.5"
+name = "data-encoding-macro"
+version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
+checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c"
dependencies = [
- "powerfmt",
- "serde_core",
+ "data-encoding",
+ "data-encoding-macro-internal",
]
[[package]]
-name = "derive_more"
-version = "2.1.1"
+name = "data-encoding-macro-internal"
+version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
dependencies = [
- "derive_more-impl",
+ "data-encoding",
+ "syn 2.0.117",
]
[[package]]
-name = "derive_more-impl"
-version = "2.1.1"
+name = "datagram-socket"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+checksum = "8b2dc498ae9ce30da432498ea95297c7ed80d09a88ea4dce4ba6db2126eef60b"
dependencies = [
- "convert_case",
- "proc-macro2",
- "quote",
+ "buffer-pool",
+ "futures-util",
+ "libc",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "deadpool"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
+dependencies = [
+ "deadpool-runtime",
+ "lazy_static",
+ "num_cpus",
+ "tokio",
+]
+
+[[package]]
+name = "deadpool-runtime"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid 0.9.6",
+ "pem-rfc7468 0.7.0",
+ "zeroize",
+]
+
+[[package]]
+name = "der"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b"
+dependencies = [
+ "const-oid 0.10.2",
+ "pem-rfc7468 1.0.0",
+ "zeroize",
+]
+
+[[package]]
+name = "der-parser"
+version = "10.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom",
+ "num-bigint",
+ "num-traits",
+ "rusticata-macros",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+ "serde_core",
+]
+
+[[package]]
+name = "derive_builder"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
+dependencies = [
+ "derive_builder_macro",
+]
+
+[[package]]
+name = "derive_builder_core"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
+dependencies = [
+ "darling 0.20.11",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "derive_builder_macro"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
+dependencies = [
+ "derive_builder_core",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
"rustc_version",
- "syn",
+ "syn 2.0.117",
"unicode-xid",
]
@@ -1059,37 +1504,49 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"const-oid 0.9.6",
- "crypto-common 0.1.6",
+ "crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
-version = "0.11.0-rc.3"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dac89f8a64533a9b0eaa73a68e424db0fb1fd6271c74cc0125336a05f090568d"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
- "block-buffer 0.11.0",
+ "block-buffer 0.12.0",
"const-oid 0.10.2",
- "crypto-common 0.2.0-rc.4",
+ "crypto-common 0.2.2",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags",
+ "block2",
+ "libc",
+ "objc2",
]
[[package]]
name = "displaydoc"
-version = "0.2.5"
+version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
name = "dlopen2"
-version = "0.5.0"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa"
+checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"
dependencies = [
"libc",
"once_cell",
@@ -1097,13 +1554,10 @@ dependencies = [
]
[[package]]
-name = "document-features"
-version = "0.2.12"
+name = "dtoa"
+version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
-dependencies = [
- "litrs",
-]
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
[[package]]
name = "dunce"
@@ -1117,6 +1571,35 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+[[package]]
+name = "ebml-iterable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5173ac3752f08b526a6991509615e1a345b221ec3c58c7633433e8c9582312"
+dependencies = [
+ "ebml-iterable-specification",
+ "ebml-iterable-specification-derive",
+ "futures",
+]
+
+[[package]]
+name = "ebml-iterable-specification"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f56467af159a98735d44231f53eaa505e919e6003266f103b99649a93f106784"
+
+[[package]]
+name = "ebml-iterable-specification-derive"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b066b81018300fdce40f71c4db355a102699324af96fad28f25ab1b5f87de066"
+dependencies = [
+ "ebml-iterable-specification",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
[[package]]
name = "ecdsa"
version = "0.16.9"
@@ -1133,36 +1616,36 @@ dependencies = [
[[package]]
name = "ed25519"
-version = "3.0.0-rc.2"
+version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "594435fe09e345ee388e4e8422072ff7dfeca8729389fbd997b3f5504c44cd47"
+checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
dependencies = [
- "pkcs8 0.11.0-rc.8",
- "serde",
- "signature 3.0.0-rc.6",
+ "pkcs8 0.11.0",
+ "serdect",
+ "signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
-version = "3.0.0-pre.1"
+version = "3.0.0-pre.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1"
+checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6"
dependencies = [
"curve25519-dalek",
"ed25519",
- "rand_core 0.9.3",
+ "rand_core 0.10.1",
"serde",
- "sha2 0.11.0-rc.2",
- "signature 3.0.0-rc.6",
+ "sha2 0.11.0",
+ "signature 3.0.0",
"subtle",
"zeroize",
]
[[package]]
name = "either"
-version = "1.15.0"
+version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "elliptic-curve"
@@ -1198,15 +1681,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
-name = "enum-as-inner"
-version = "0.6.1"
+name = "enum-assoc"
+version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
+checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7"
dependencies = [
- "heck",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "enum_dispatch"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
+dependencies = [
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
@@ -1215,6 +1709,26 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+[[package]]
+name = "erased-serde"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "erased-serde"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
+dependencies = [
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
[[package]]
name = "errno"
version = "0.3.14"
@@ -1248,21 +1762,21 @@ dependencies = [
[[package]]
name = "fastbloom"
-version = "0.14.0"
+version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18c1ddb9231d8554c2d6bdf4cfaabf0c59251658c68b6c95cd52dd0c513a912a"
+checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4"
dependencies = [
"getrandom 0.3.4",
"libm",
- "rand 0.9.2",
+ "rand 0.9.4",
"siphasher",
]
[[package]]
name = "fastrand"
-version = "2.3.0"
+version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "ff"
@@ -1274,6 +1788,31 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "ffmpeg-next"
+version = "8.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d"
+dependencies = [
+ "bitflags",
+ "ffmpeg-sys-next",
+ "libc",
+]
+
+[[package]]
+name = "ffmpeg-sys-next"
+version = "8.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a314bc0e022a33a99567ed4bd2576bd58ffd8fcff7891c29194cfecc26a62547"
+dependencies = [
+ "bindgen",
+ "cc",
+ "libc",
+ "num_cpus",
+ "pkg-config",
+ "vcpkg",
+]
+
[[package]]
name = "fiat-crypto"
version = "0.3.0"
@@ -1282,9 +1821,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "find-msvc-tools"
-version = "0.1.7"
+version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
@@ -1294,20 +1833,38 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "flate2"
-version = "1.1.5"
+version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
+[[package]]
+name = "flume"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "nanorand",
+ "spin 0.9.8",
+]
+
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1315,90 +1872,197 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
-name = "form_urlencoded"
-version = "1.2.2"
+name = "foreign-types"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
- "percent-encoding",
+ "foreign-types-macros",
+ "foreign-types-shared",
]
[[package]]
-name = "fs-err"
-version = "3.2.2"
+name = "foreign-types-macros"
+version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf68cef89750956493a66a10f512b9e58d9db21f2a573c079c0bdf1207a54a7"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [
- "autocfg",
- "tokio",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
-name = "fs_extra"
-version = "1.3.0"
+name = "foreign-types-shared"
+version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
-name = "futures"
-version = "0.3.31"
+name = "form_urlencoded"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
- "futures-channel",
- "futures-core",
- "futures-executor",
- "futures-io",
- "futures-sink",
- "futures-task",
- "futures-util",
+ "percent-encoding",
]
[[package]]
-name = "futures-buffered"
-version = "0.2.12"
+name = "foundations"
+version = "5.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8e0e1f38ec07ba4abbde21eed377082f17ccb988be9d988a5adbf4bafc118fd"
+checksum = "f8842ece95ec0b71f0dbf9cb1da218ebdc6a40536bb97ea9c1974978058912b9"
dependencies = [
- "cordyceps",
- "diatomic-waker",
- "futures-core",
+ "anyhow",
+ "cf-rustracing",
+ "cf-rustracing-jaeger",
+ "crossbeam-utils",
+ "erased-serde 0.4.10",
+ "foundations-macros",
+ "futures-util",
+ "governor",
+ "http",
+ "indexmap 2.14.0",
+ "libc",
+ "opentelemetry-proto",
+ "parking_lot",
"pin-project-lite",
- "spin 0.10.0",
+ "prometheus",
+ "prometheus-client",
+ "prometools",
+ "rand 0.10.1",
+ "serde",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_with",
+ "serde_yaml",
+ "slab",
+ "slog",
+ "slog-async",
+ "slog-json",
+ "slog-term",
+ "thread_local",
+ "tokio",
+ "yaml-merge-keys",
+ "zeroize",
]
[[package]]
-name = "futures-channel"
-version = "0.3.31"
+name = "foundations-macros"
+version = "5.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
+checksum = "ef32f15128fcc7706efb662b46b92bcad51c1e92cd691320864efde9e5e218c0"
dependencies = [
- "futures-core",
- "futures-sink",
+ "darling 0.23.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
-name = "futures-core"
-version = "0.3.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
-
-[[package]]
-name = "futures-executor"
-version = "0.3.31"
+name = "fs-err"
+version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
+checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41"
dependencies = [
- "futures-core",
- "futures-task",
- "futures-util",
+ "autocfg",
+]
+
+[[package]]
+name = "fs-err"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
+dependencies = [
+ "autocfg",
+ "tokio",
+]
+
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "fslock"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-buffered"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5"
+dependencies = [
+ "cordyceps",
+ "diatomic-waker",
+ "futures-core",
+ "pin-project-lite",
+ "spin 0.10.0",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
]
[[package]]
name = "futures-io"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
@@ -1415,32 +2079,38 @@ dependencies = [
[[package]]
name = "futures-macro"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
name = "futures-sink"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
-version = "0.3.31"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-timer"
+version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
@@ -1450,7 +2120,6 @@ dependencies = [
"futures-task",
"memchr",
"pin-project-lite",
- "pin-utils",
"slab",
]
@@ -1471,9 +2140,9 @@ dependencies = [
[[package]]
name = "generic-array"
-version = "0.14.9"
+version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
@@ -1482,9 +2151,9 @@ dependencies = [
[[package]]
name = "getrandom"
-version = "0.2.16"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
@@ -1502,17 +2171,118 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
- "r-efi",
+ "r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 6.0.0",
+ "rand_core 0.10.1",
+ "wasip2",
+ "wasip3",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getset"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912"
+dependencies = [
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "ghash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
[[package]]
name = "gimli"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+[[package]]
+name = "gio-sys"
+version = "0.20.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "glib"
+version = "0.20.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683"
+dependencies = [
+ "bitflags",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-task",
+ "futures-util",
+ "gio-sys",
+ "glib-macros",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "memchr",
+ "smallvec",
+]
+
+[[package]]
+name = "glib-macros"
+version = "0.20.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145"
+dependencies = [
+ "heck",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "glib-sys"
+version = "0.20.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215"
+dependencies = [
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
[[package]]
name = "gloo-timers"
version = "0.3.0"
@@ -1525,6 +2295,51 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "gobject-sys"
+version = "0.20.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "goblin"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47"
+dependencies = [
+ "log",
+ "plain",
+ "scroll",
+]
+
+[[package]]
+name = "governor"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
+dependencies = [
+ "cfg-if",
+ "dashmap",
+ "futures-sink",
+ "futures-timer",
+ "futures-util",
+ "getrandom 0.3.4",
+ "hashbrown 0.16.1",
+ "nonzero_ext",
+ "parking_lot",
+ "portable-atomic",
+ "quanta",
+ "rand 0.9.4",
+ "smallvec",
+ "spinning_top",
+ "web-time",
+]
+
[[package]]
name = "group"
version = "0.13.0"
@@ -1536,11 +2351,58 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "gst-plugin-version-helper"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94668bc2592732b8c2b653668ae41211d45988fb61264888b9c2d545d4bd826d"
+dependencies = [
+ "chrono",
+ "toml_edit 0.25.12+spec-1.1.0",
+]
+
+[[package]]
+name = "gstreamer"
+version = "0.23.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8757a87f3706560037a01a9f06a59fcc7bdb0864744dcf73546606e60c4316e1"
+dependencies = [
+ "cfg-if",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "glib",
+ "gstreamer-sys",
+ "itertools 0.14.0",
+ "libc",
+ "muldiv",
+ "num-integer",
+ "num-rational",
+ "once_cell",
+ "option-operations",
+ "paste",
+ "pin-project-lite",
+ "smallvec",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gstreamer-sys"
+version = "0.23.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "feea73b4d92dbf9c24a203c9cd0bcc740d584f6b5960d5faf359febf288919b2"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
[[package]]
name = "h2"
-version = "0.4.13"
+version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
+checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
dependencies = [
"atomic-waker",
"bytes",
@@ -1548,7 +2410,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http",
- "indexmap 2.13.0",
+ "indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
@@ -1563,66 +2425,46 @@ checksum = "253b313319f7109de64e480ffb606f89475cd758bae82e096e00c5d95341d30e"
[[package]]
name = "hang"
-version = "0.10.0"
+version = "0.18.1"
dependencies = [
"anyhow",
- "buf-list",
"bytes",
"derive_more",
- "futures",
- "h264-parser",
"hex",
"lazy_static",
- "m3u8-rs",
- "moq-lite",
+ "moq-mux",
"moq-native",
- "mp4-atom",
- "num_enum",
+ "moq-net",
"regex",
- "reqwest",
- "scuffle-h265",
"serde",
"serde_json",
"serde_with",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"tokio",
"tracing",
"url",
]
[[package]]
-name = "hang-cli"
-version = "0.7.3"
-dependencies = [
- "anyhow",
- "axum",
- "axum-server",
- "bytes",
- "clap",
- "hang",
- "moq-native",
- "rustls",
- "sd-notify",
- "tokio",
- "tower-http",
- "tracing",
- "url",
-]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
-name = "hash32"
-version = "0.2.1"
+name = "hashbrown"
+version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
-dependencies = [
- "byteorder",
-]
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
-version = "0.12.3"
+version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash 0.1.5",
+]
[[package]]
name = "hashbrown"
@@ -1632,7 +2474,18 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash",
+ "foldhash 0.2.0",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
]
[[package]]
@@ -1648,26 +2501,18 @@ dependencies = [
"num-traits",
]
-[[package]]
-name = "heapless"
-version = "0.7.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
-dependencies = [
- "atomic-polyfill",
- "hash32",
- "rustc_version",
- "serde",
- "spin 0.9.8",
- "stable_deref_trait",
-]
-
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
[[package]]
name = "hex"
version = "0.4.3"
@@ -1675,28 +2520,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
-name = "hickory-proto"
-version = "0.25.2"
+name = "hickory-net"
+version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502"
+checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183"
dependencies = [
"async-trait",
"bytes",
"cfg-if",
"data-encoding",
- "enum-as-inner",
"futures-channel",
"futures-io",
"futures-util",
"h2",
+ "hickory-proto",
"http",
"idna",
"ipnet",
- "once_cell",
- "rand 0.9.2",
- "ring",
+ "jni 0.22.4",
+ "rand 0.10.1",
"rustls",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"tinyvec",
"tokio",
"tokio-rustls",
@@ -1704,24 +2548,49 @@ dependencies = [
"url",
]
+[[package]]
+name = "hickory-proto"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643"
+dependencies = [
+ "data-encoding",
+ "idna",
+ "ipnet",
+ "jni 0.22.4",
+ "once_cell",
+ "prefix-trie",
+ "rand 0.10.1",
+ "ring",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "url",
+]
+
[[package]]
name = "hickory-resolver"
-version = "0.25.2"
+version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
+checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c"
dependencies = [
"cfg-if",
"futures-util",
+ "hickory-net",
"hickory-proto",
"ipconfig",
+ "ipnet",
+ "jni 0.22.4",
"moka",
+ "ndk-context",
"once_cell",
"parking_lot",
- "rand 0.9.2",
+ "rand 0.10.1",
"resolv-conf",
"rustls",
"smallvec",
- "thiserror 2.0.17",
+ "system-configuration",
+ "thiserror 2.0.18",
"tokio",
"tokio-rustls",
"tracing",
@@ -1745,11 +2614,22 @@ dependencies = [
"digest 0.10.7",
]
+[[package]]
+name = "hostname"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "windows-link",
+]
+
[[package]]
name = "http"
-version = "1.4.0"
+version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [
"bytes",
"itoa",
@@ -1778,12 +2658,67 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "http-cache"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1437561a949a2168929d7559aecd2f0ecb18b9e676080396388cdc399ddb723a"
+dependencies = [
+ "async-trait",
+ "bincode",
+ "http",
+ "http-cache-semantics",
+ "httpdate",
+ "moka",
+ "serde",
+ "url",
+]
+
+[[package]]
+name = "http-cache-reqwest"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ccca775a066b2a65da33611921e078ebdcfc27065c8bee96794703541101886"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "http",
+ "http-cache",
+ "http-cache-semantics",
+ "reqwest 0.12.28",
+ "reqwest-middleware",
+ "serde",
+ "url",
+]
+
+[[package]]
+name = "http-cache-semantics"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4311240f94cb6fe622337dc580b09ddd6ae4a891eb121dba20cf4f77ca4e7129"
+dependencies = [
+ "http",
+ "http-serde",
+ "serde",
+ "time",
+]
+
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+[[package]]
+name = "http-serde"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd"
+dependencies = [
+ "http",
+ "serde",
+]
+
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1814,19 +2749,18 @@ dependencies = [
[[package]]
name = "hybrid-array"
-version = "0.4.5"
+version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f471e0a81b2f90ffc0cb2f951ae04da57de8baa46fa99112b062a5173a5088d0"
+checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
dependencies = [
"typenum",
- "zeroize",
]
[[package]]
name = "hyper"
-version = "1.8.1"
+version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
@@ -1839,7 +2773,6 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
- "pin-utils",
"smallvec",
"tokio",
"want",
@@ -1847,19 +2780,18 @@ dependencies = [
[[package]]
name = "hyper-rustls"
-version = "0.27.7"
+version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
- "rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
- "webpki-roots 1.0.5",
+ "webpki-roots",
]
[[package]]
@@ -1877,14 +2809,13 @@ dependencies = [
[[package]]
name = "hyper-util"
-version = "0.1.19"
+version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
- "futures-core",
"futures-util",
"http",
"http-body",
@@ -1893,7 +2824,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2 0.6.1",
+ "socket2",
"tokio",
"tower-service",
"tracing",
@@ -1901,9 +2832,9 @@ dependencies = [
[[package]]
name = "iana-time-zone"
-version = "0.1.64"
+version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
@@ -1925,12 +2856,13 @@ dependencies = [
[[package]]
name = "icu_collections"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
+ "utf8_iter",
"yoke",
"zerofrom",
"zerovec",
@@ -1938,9 +2870,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
@@ -1951,9 +2883,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -1965,15 +2897,15 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
-version = "2.1.2"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
@@ -1985,15 +2917,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
-version = "2.1.2"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -2004,12 +2936,24 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+[[package]]
+name = "identity-hash"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da"
+
[[package]]
name = "idna"
version = "1.1.0"
@@ -2023,9 +2967,9 @@ dependencies = [
[[package]]
name = "idna_adapter"
-version = "1.2.1"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
@@ -2033,11 +2977,10 @@ dependencies = [
[[package]]
name = "igd-next"
-version = "0.16.2"
+version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97"
+checksum = "bac9a3c8278f43b4cd8463380f4a25653ac843e5b177e1d3eaf849cc9ba10d4d"
dependencies = [
- "async-trait",
"attohttpc",
"bytes",
"futures",
@@ -2046,7 +2989,7 @@ dependencies = [
"hyper",
"hyper-util",
"log",
- "rand 0.9.2",
+ "rand 0.10.1",
"tokio",
"url",
"xmltree",
@@ -2065,105 +3008,124 @@ dependencies = [
[[package]]
name = "indexmap"
-version = "2.13.0"
+version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
- "hashbrown 0.16.1",
+ "hashbrown 0.17.1",
"serde",
"serde_core",
]
+[[package]]
+name = "inotify"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
+dependencies = [
+ "bitflags",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "inout"
-version = "0.2.2"
+version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
- "hybrid-array",
+ "generic-array",
]
[[package]]
-name = "instant"
-version = "0.1.13"
+name = "intrusive-collections"
+version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86"
dependencies = [
- "cfg-if",
- "js-sys",
- "wasm-bindgen",
- "web-sys",
+ "memoffset",
]
[[package]]
name = "ipconfig"
-version = "0.3.2"
+version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
+checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
- "socket2 0.5.10",
+ "socket2",
"widestring",
- "windows-sys 0.48.0",
- "winreg",
+ "windows-registry",
+ "windows-result",
+ "windows-sys 0.61.2",
]
[[package]]
name = "ipnet"
-version = "2.11.0"
+version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+dependencies = [
+ "serde",
+]
[[package]]
-name = "iri-string"
-version = "0.7.10"
+name = "ipnetwork"
+version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
+checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
dependencies = [
- "memchr",
"serde",
]
[[package]]
name = "iroh"
-version = "0.95.1"
+version = "1.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2374ba3cdaac152dc6ada92d971f7328e6408286faab3b7350842b2ebbed4789"
+checksum = "b98e206e3d3f2642f5c08c413755fc0ac19b54ae1a656af88be03454ce3ed2e6"
dependencies = [
- "aead",
"backon",
+ "blake3",
"bytes",
"cfg_aliases",
- "crypto_box",
+ "ctutils",
"data-encoding",
"derive_more",
"ed25519-dalek",
"futures-util",
- "getrandom 0.3.4",
+ "getrandom 0.4.2",
"hickory-resolver",
"http",
- "igd-next",
- "instant",
+ "ipnet",
"iroh-base",
+ "iroh-dns",
"iroh-metrics",
- "iroh-quinn",
- "iroh-quinn-proto",
- "iroh-quinn-udp",
"iroh-relay",
"n0-error",
"n0-future",
"n0-watcher",
- "netdev",
"netwatch",
+ "noq 1.0.0-rc.0",
+ "noq-proto 1.0.0-rc.0",
+ "noq-udp 1.0.0-rc.0",
+ "papaya",
"pin-project",
- "pkarr",
- "pkcs8 0.11.0-rc.8",
+ "portable-atomic",
"portmapper",
- "rand 0.9.2",
- "reqwest",
+ "rand 0.10.1",
+ "reqwest 0.13.4",
+ "rustc-hash",
"rustls",
"rustls-pki-types",
- "rustls-platform-verifier 0.5.3",
"rustls-webpki",
"serde",
"smallvec",
@@ -2175,145 +3137,116 @@ dependencies = [
"tracing",
"url",
"wasm-bindgen-futures",
- "webpki-roots 1.0.5",
- "z32",
+ "webpki-roots",
]
[[package]]
name = "iroh-base"
-version = "0.95.1"
+version = "1.0.0-rc.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25a8c5fb1cc65589f0d7ab44269a76f615a8c4458356952c9b0ef1c93ea45ff8"
+checksum = "af93d67701c00c504982154569192ad384738c0450ba1196930314b955100552"
dependencies = [
"curve25519-dalek",
"data-encoding",
+ "data-encoding-macro",
"derive_more",
+ "digest 0.11.3",
"ed25519-dalek",
+ "getrandom 0.4.2",
"n0-error",
- "rand_core 0.9.3",
+ "rand 0.10.1",
"serde",
+ "sha2 0.11.0",
"url",
"zeroize",
"zeroize_derive",
]
[[package]]
-name = "iroh-metrics"
-version = "0.37.0"
+name = "iroh-dns"
+version = "1.0.0-rc.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "79e3381da7c93c12d353230c74bba26131d1c8bf3a4d8af0fec041546454582e"
+checksum = "de4112c91eb64094d77df9d3112606dcf7ff216421afccd2dc762fda5a7b2879"
dependencies = [
- "iroh-metrics-derive",
- "itoa",
- "n0-error",
- "postcard",
- "ryu",
- "serde",
- "tracing",
-]
-
-[[package]]
-name = "iroh-metrics-derive"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4e12bd0763fd16062f5cc5e8db15dd52d26e75a8af4c7fb57ccee3589b344b8"
-dependencies = [
- "heck",
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "iroh-quinn"
-version = "0.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0cde160ebee7aabede6ae887460cd303c8b809054224815addf1469d54a6fcf7"
-dependencies = [
- "bytes",
+ "arc-swap",
"cfg_aliases",
- "iroh-quinn-proto",
- "iroh-quinn-udp",
- "pin-project-lite",
- "rustc-hash",
+ "derive_more",
+ "hickory-resolver",
+ "iroh-base",
+ "n0-error",
+ "n0-future",
+ "ndk-context",
+ "rand 0.10.1",
+ "reqwest 0.13.4",
"rustls",
- "socket2 0.5.10",
- "thiserror 2.0.17",
+ "simple-dns",
+ "strum",
"tokio",
"tracing",
- "web-time",
+ "url",
]
[[package]]
-name = "iroh-quinn-proto"
-version = "0.13.0"
+name = "iroh-metrics"
+version = "1.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "929d5d8fa77d5c304d3ee7cae9aede31f13908bd049f9de8c7c0094ad6f7c535"
+checksum = "d102597d0ee523f17fdb672c532395e634dbe945429284c811430d63bacc0d8a"
dependencies = [
- "bytes",
- "getrandom 0.2.16",
- "rand 0.8.5",
- "ring",
- "rustc-hash",
- "rustls",
- "rustls-pki-types",
- "rustls-platform-verifier 0.5.3",
- "slab",
- "thiserror 2.0.17",
- "tinyvec",
+ "iroh-metrics-derive",
+ "itoa",
+ "n0-error",
+ "portable-atomic",
+ "ryu",
+ "serde",
"tracing",
- "web-time",
]
[[package]]
-name = "iroh-quinn-udp"
-version = "0.5.7"
+name = "iroh-metrics-derive"
+version = "1.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c53afaa1049f7c83ea1331f5ebb9e6ebc5fdd69c468b7a22dd598b02c9bcc973"
+checksum = "91c8e0c97f1dc787107f388433c349397c565572fe6406d600ff7bb7b7fe3b30"
dependencies = [
- "cfg_aliases",
- "libc",
- "once_cell",
- "socket2 0.5.10",
- "tracing",
- "windows-sys 0.59.0",
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
name = "iroh-relay"
-version = "0.95.1"
+version = "1.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43fbdf2aeffa7d6ede1a31f6570866c2199b1cee96a0b563994623795d1bac2c"
+checksum = "54f490405e42dd2ecf16be18a3587d2665401e94a498094f12322eaa6d5ebb2b"
dependencies = [
"blake3",
"bytes",
"cfg_aliases",
"data-encoding",
"derive_more",
- "getrandom 0.3.4",
+ "getrandom 0.4.2",
"hickory-resolver",
"http",
"http-body-util",
"hyper",
"hyper-util",
"iroh-base",
+ "iroh-dns",
"iroh-metrics",
- "iroh-quinn",
- "iroh-quinn-proto",
"lru",
"n0-error",
"n0-future",
+ "noq 1.0.0-rc.0",
+ "noq-proto 1.0.0-rc.0",
"num_enum",
"pin-project",
- "pkarr",
"postcard",
- "rand 0.9.2",
- "reqwest",
+ "rand 0.10.1",
+ "reqwest 0.13.4",
"rustls",
"rustls-pki-types",
"serde",
"serde_bytes",
- "sha1 0.11.0-rc.2",
"strum",
"tokio",
"tokio-rustls",
@@ -2321,9 +3254,20 @@ dependencies = [
"tokio-websockets",
"tracing",
"url",
- "webpki-roots 1.0.5",
+ "vergen-gitcl",
+ "webpki-roots",
"ws_stream_wasm",
- "z32",
+]
+
+[[package]]
+name = "is-terminal"
+version = "0.4.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2332,6 +3276,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2343,9 +3296,9 @@ dependencies = [
[[package]]
name = "itoa"
-version = "1.0.17"
+version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
@@ -2356,18 +3309,70 @@ dependencies = [
"cesu8",
"cfg-if",
"combine",
- "jni-sys",
+ "jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys 0.4.1",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.18",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.117",
+]
+
[[package]]
name = "jni-sys"
-version = "0.3.0"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
[[package]]
name = "jobserver"
@@ -2381,29 +3386,59 @@ dependencies = [
[[package]]
name = "js-sys"
-version = "0.3.83"
+version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
+checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
dependencies = [
+ "cfg-if",
+ "futures-util",
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
-version = "10.2.0"
+version = "10.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e"
+checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
dependencies = [
"aws-lc-rs",
"base64 0.22.1",
- "getrandom 0.2.16",
+ "getrandom 0.2.17",
"js-sys",
"pem",
"serde",
"serde_json",
"signature 2.2.0",
"simple_asn1",
+ "zeroize",
+]
+
+[[package]]
+name = "kio"
+version = "0.3.0"
+dependencies = [
+ "smallvec",
+]
+
+[[package]]
+name = "kqueue"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags",
+ "libc",
]
[[package]]
@@ -2415,51 +3450,86 @@ dependencies = [
"spin 0.9.8",
]
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
[[package]]
name = "libc"
-version = "0.2.180"
+version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
[[package]]
name = "libm"
-version = "0.2.15"
+version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmoq"
-version = "0.2.4"
+version = "0.3.1"
dependencies = [
"anyhow",
+ "bytes",
"cbindgen",
"hang",
- "moq-lite",
+ "moq-audio",
+ "moq-mux",
"moq-native",
- "slab",
- "thiserror 2.0.17",
+ "moq-net",
+ "thiserror 2.0.18",
"tokio",
"tracing",
"url",
]
+[[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
[[package]]
name = "linux-raw-sys"
-version = "0.11.0"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
-name = "litrs"
-version = "1.0.0"
+name = "local-ip-address"
+version = "0.6.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3"
+dependencies = [
+ "libc",
+ "neli",
+ "windows-sys 0.61.2",
+]
[[package]]
name = "lock_api"
@@ -2472,9 +3542,9 @@ dependencies = [
[[package]]
name = "log"
-version = "0.4.29"
+version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]]
name = "loom"
@@ -2491,11 +3561,11 @@ dependencies = [
[[package]]
name = "lru"
-version = "0.16.3"
+version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
+checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
dependencies = [
- "hashbrown 0.16.1",
+ "hashbrown 0.17.1",
]
[[package]]
@@ -2506,14 +3576,20 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "m3u8-rs"
-version = "5.0.5"
+version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c1d7ba86f7ea62f17f4310c55e93244619ddc7dadfc7e565de1967e4e41e6e7"
+checksum = "f03cd3335fb5f2447755d45cda9c70f76013626a9db44374973791b0926a86c3"
dependencies = [
"chrono",
"nom",
]
+[[package]]
+name = "mac-addr"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
+
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2531,9 +3607,18 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
-version = "2.7.6"
+version = "2.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
[[package]]
name = "mime"
@@ -2569,25 +3654,29 @@ dependencies = [
[[package]]
name = "mio"
-version = "1.1.1"
+version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
+ "log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "moka"
-version = "0.12.12"
+version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3dec6bd31b08944e08b58fd99373893a6c17054d6f3ea5006cc894f4f4eee2a"
+checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
dependencies = [
+ "async-lock",
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"equivalent",
+ "event-listener",
+ "futures-util",
"parking_lot",
"portable-atomic",
"smallvec",
@@ -2596,74 +3685,203 @@ dependencies = [
]
[[package]]
-name = "moq-clock"
-version = "0.10.2"
+name = "moq-audio"
+version = "0.0.1"
+dependencies = [
+ "bytes",
+ "hang",
+ "moq-mux",
+ "moq-net",
+ "rubato",
+ "thiserror 2.0.18",
+ "tokio",
+ "unsafe-libopus",
+]
+
+[[package]]
+name = "moq-boy"
+version = "0.2.15"
dependencies = [
"anyhow",
- "chrono",
+ "boytacean",
+ "bytes",
"clap",
- "moq-lite",
+ "ffmpeg-next",
+ "hang",
+ "moq-mux",
"moq-native",
+ "moq-net",
+ "serde",
+ "serde_json",
"tokio",
"tracing",
"url",
]
[[package]]
-name = "moq-lite"
-version = "0.11.0"
+name = "moq-cli"
+version = "0.7.28"
+dependencies = [
+ "anyhow",
+ "axum",
+ "axum-server",
+ "bytes",
+ "clap",
+ "hang",
+ "humantime",
+ "moq-mux",
+ "moq-native",
+ "rustls",
+ "sd-notify",
+ "tokio",
+ "tower-http",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "moq-ffi"
+version = "0.2.17"
+dependencies = [
+ "bytes",
+ "hang",
+ "moq-audio",
+ "moq-mux",
+ "moq-native",
+ "moq-net",
+ "pollster",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "uniffi",
+ "url",
+]
+
+[[package]]
+name = "moq-gst"
+version = "0.2.2"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "gst-plugin-version-helper",
+ "gstreamer",
+ "hang",
+ "moq-mux",
+ "moq-native",
+ "moq-net",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+]
+
+[[package]]
+name = "moq-loc"
+version = "0.1.0"
+dependencies = [
+ "bytes",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "moq-msf"
+version = "0.2.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serde_with",
+]
+
+[[package]]
+name = "moq-mux"
+version = "0.5.2"
dependencies = [
- "async-channel",
+ "anyhow",
+ "base64 0.22.1",
"bytes",
"futures",
- "hex",
+ "h264-parser",
+ "hang",
+ "kio",
+ "m3u8-rs",
+ "moq-loc",
+ "moq-msf",
+ "moq-net",
+ "mp4-atom",
"num_enum",
- "rand 0.9.2",
- "serde",
- "thiserror 2.0.17",
+ "reqwest 0.12.28",
+ "scuffle-av1",
+ "scuffle-h265",
+ "thiserror 2.0.18",
"tokio",
"tracing",
- "web-async",
- "web-transport-trait",
+ "url",
+ "webm-iterable",
]
[[package]]
name = "moq-native"
-version = "0.11.0"
+version = "0.16.1"
dependencies = [
"anyhow",
"bytes",
+ "chrono",
"clap",
"console-subscriber",
"futures",
"hex",
"humantime",
"humantime-serde",
- "moq-lite",
+ "moq-net",
"parking_lot",
+ "qmux",
"quinn",
- "rand 0.9.2",
- "rcgen",
- "reqwest",
+ "rand 0.9.4",
+ "rcgen 0.14.8",
+ "reqwest 0.12.28",
"rustls",
"rustls-native-certs",
- "rustls-pemfile",
"rustls-webpki",
"serde",
"serde_with",
+ "tikv-jemalloc-ctl",
+ "tikv-jemallocator",
"time",
"tokio",
+ "toml 0.9.12+spec-1.1.0",
"tracing",
+ "tracing-android",
"tracing-subscriber",
+ "tracing-test",
"url",
"web-transport-iroh",
+ "web-transport-noq",
+ "web-transport-proto",
+ "web-transport-quiche",
"web-transport-quinn",
- "web-transport-ws",
+]
+
+[[package]]
+name = "moq-net"
+version = "0.1.8"
+dependencies = [
+ "bytes",
+ "futures",
+ "kio",
+ "num_enum",
+ "rand 0.9.4",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-async",
+ "web-transport-trait",
]
[[package]]
name = "moq-relay"
-version = "0.10.2"
+version = "0.12.5"
dependencies = [
"anyhow",
"axum",
@@ -2672,26 +3890,37 @@ dependencies = [
"clap",
"futures",
"http-body",
- "moq-lite",
+ "http-cache-reqwest",
+ "jsonwebtoken",
"moq-native",
+ "moq-net",
"moq-token",
+ "notify",
+ "qmux",
+ "rcgen 0.13.2",
+ "reqwest 0.12.28",
+ "reqwest-middleware",
"rustls",
"sd-notify",
"serde",
+ "serde_json",
"serde_with",
"tempfile",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"tokio",
- "toml",
+ "tokio-rustls",
+ "toml 0.9.12+spec-1.1.0",
"tower-http",
+ "tower-service",
"tracing",
"url",
- "web-transport-ws",
+ "web-transport-trait",
+ "wiremock",
]
[[package]]
name = "moq-token"
-version = "0.5.6"
+version = "0.6.0"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -2700,42 +3929,55 @@ dependencies = [
"jsonwebtoken",
"p256",
"p384",
+ "reqwest 0.12.28",
"rsa",
"serde",
"serde_json",
"serde_with",
+ "thiserror 2.0.18",
+ "tokio",
]
[[package]]
name = "moq-token-cli"
-version = "0.5.7"
+version = "0.5.28"
dependencies = [
"anyhow",
"clap",
"moq-token",
]
+[[package]]
+name = "moq-video"
+version = "0.0.2"
+
[[package]]
name = "mp4-atom"
-version = "0.10.0"
+version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb3af4894763f3d6e1eabd3d878853d88ad90add9bcff9165116fb84cbfb0207"
+checksum = "6c6b338be0a8d66954dfb183f8469913c9c435d48885e801debde0953de74563"
dependencies = [
"bytes",
"derive_more",
"num",
- "paste",
+ "pastey",
"serde",
"thiserror 1.0.69",
"tokio",
"tracing",
]
+[[package]]
+name = "muldiv"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0"
+
[[package]]
name = "n0-error"
-version = "0.1.2"
+version = "1.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7d5969a2f40e9d9ed121a789c415f4114ac2b28e5731c080bdefee217d3b3fb"
+checksum = "223e946a84aa91644507a6b7865cfebbb9a231ace499041c747ab0fd30408212"
dependencies = [
"n0-error-macros",
"spez",
@@ -2743,13 +3985,13 @@ dependencies = [
[[package]]
name = "n0-error-macros"
-version = "0.1.2"
+version = "1.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a6908df844696d9af91c7c3950d50e52d67df327d02a95367f95bbf177d6556"
+checksum = "565305a21e6b3bf26640ad98f05a0fda12d3ab4315394566b52a7bddb8b34828"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -2775,30 +4017,81 @@ dependencies = [
[[package]]
name = "n0-watcher"
-version = "0.5.0"
+version = "1.0.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38acf13c1ddafc60eb7316d52213467f8ccb70b6f02b65e7d97f7799b1f50be4"
+checksum = "928d8039a66cce5efcfd35e88b32d3defc8eba630b3ac451522997f563956a52"
dependencies = [
"derive_more",
"n0-error",
"n0-future",
]
+[[package]]
+name = "nanorand"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "neli"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87"
+dependencies = [
+ "bitflags",
+ "byteorder",
+ "derive_builder",
+ "getset",
+ "libc",
+ "log",
+ "neli-proc-macros",
+ "parking_lot",
+]
+
+[[package]]
+name = "neli-proc-macros"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609"
+dependencies = [
+ "either",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 2.0.117",
+]
+
[[package]]
name = "netdev"
-version = "0.38.2"
+version = "0.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67ab878b4c90faf36dab10ea51d48c69ae9019bcca47c048a7c9b273d5d7a823"
+checksum = "57bacaf873ee4eab5646f99b381b271ec75e716902a67cf962c0f328c5eb5bfb"
dependencies = [
+ "block2",
+ "dispatch2",
"dlopen2",
"ipnet",
"libc",
+ "mac-addr",
"netlink-packet-core",
- "netlink-packet-route",
+ "netlink-packet-route 0.29.0",
"netlink-sys",
+ "objc2-core-foundation",
+ "objc2-core-wlan",
+ "objc2-foundation",
+ "objc2-system-configuration",
"once_cell",
- "system-configuration",
- "windows-sys 0.59.0",
+ "plist",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2812,9 +4105,21 @@ dependencies = [
[[package]]
name = "netlink-packet-route"
-version = "0.25.1"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6"
+dependencies = [
+ "bitflags",
+ "libc",
+ "log",
+ "netlink-packet-core",
+]
+
+[[package]]
+name = "netlink-packet-route"
+version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ec2f5b6839be2a19d7fa5aab5bc444380f6311c2b693551cb80f45caaa7b5ef"
+checksum = "be8919612f6028ab4eacbbfe1234a9a43e3722c6e0915e7ff519066991905092"
dependencies = [
"bitflags",
"libc",
@@ -2833,17 +4138,17 @@ dependencies = [
"log",
"netlink-packet-core",
"netlink-sys",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
]
[[package]]
name = "netlink-sys"
-version = "0.8.7"
+version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23"
+checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae"
dependencies = [
"bytes",
- "futures",
+ "futures-util",
"libc",
"log",
"tokio",
@@ -2851,15 +4156,14 @@ dependencies = [
[[package]]
name = "netwatch"
-version = "0.12.0"
+version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26f2acd376ef48b6c326abf3ba23c449e0cb8aa5c2511d189dd8a8a3bfac889b"
+checksum = "b5bfbba77b994ce69f1d40fc66fd8abbd23df62ce4aea61fbb34d638106a2549"
dependencies = [
"atomic-waker",
"bytes",
"cfg_aliases",
"derive_more",
- "iroh-quinn-udp",
"js-sys",
"libc",
"n0-error",
@@ -2867,12 +4171,15 @@ dependencies = [
"n0-watcher",
"netdev",
"netlink-packet-core",
- "netlink-packet-route",
+ "netlink-packet-route 0.30.0",
"netlink-proto",
"netlink-sys",
+ "noq-udp 1.0.0-rc.0",
+ "objc2-core-foundation",
+ "objc2-system-configuration",
"pin-project-lite",
"serde",
- "socket2 0.6.1",
+ "socket2",
"time",
"tokio",
"tokio-util",
@@ -2883,6 +4190,19 @@ dependencies = [
"wmi",
]
+[[package]]
+name = "nix"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+ "memoffset",
+]
+
[[package]]
name = "nom"
version = "7.1.3"
@@ -2894,18 +4214,160 @@ dependencies = [
]
[[package]]
-name = "ntimestamp"
-version = "1.0.0"
+name = "nonzero_ext"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c50f94c405726d3e0095e89e72f75ce7f6587b94a8bd8dc8054b73f65c0fd68c"
+checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
+
+[[package]]
+name = "noq"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df966fb44ac763bc86da97fa6c811c54ae82ef656575949f93c6dae0c9f09bf"
dependencies = [
- "base32",
- "document-features",
- "getrandom 0.2.16",
- "httpdate",
- "js-sys",
- "once_cell",
- "serde",
+ "bytes",
+ "cfg_aliases",
+ "noq-proto 0.16.0",
+ "noq-udp 0.9.0",
+ "pin-project-lite",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "noq"
+version = "1.0.0-rc.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22739e0831e40f5ab7d6ac5317ed80bfe5fb3f44be57d23fa2eea8bff83fb303"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "derive_more",
+ "noq-proto 1.0.0-rc.0",
+ "noq-udp 1.0.0-rc.0",
+ "pin-project-lite",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "noq-proto"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c61b72abd670eebc05b5cf720e077b04a3ef3354bc7bc19f1c3524cb424db7b"
+dependencies = [
+ "aes-gcm",
+ "bytes",
+ "derive_more",
+ "enum-assoc",
+ "fastbloom",
+ "getrandom 0.3.4",
+ "identity-hash",
+ "lru-slab",
+ "rand 0.9.4",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier 0.6.2",
+ "slab",
+ "sorted-index-buffer",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "noq-proto"
+version = "1.0.0-rc.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cee32450cf726b223ac4154003c93cb52fbde159ab1240990e88945bf3ae35e"
+dependencies = [
+ "aes-gcm",
+ "aws-lc-rs",
+ "bytes",
+ "derive_more",
+ "enum-assoc",
+ "getrandom 0.4.2",
+ "identity-hash",
+ "lru-slab",
+ "rand 0.10.1",
+ "rand_pcg",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "sorted-index-buffer",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "noq-udp"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb9be4fedd6b98f3ba82ccd3506f4d0219fb723c3f97c67e12fe1494aa020e44"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "noq-udp"
+version = "1.0.0-rc.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78633d1fe1bde91d12bcabb230ac9edb890857414c6d44f3212e0d309525b5ff"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags",
]
[[package]]
@@ -2952,7 +4414,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
- "rand 0.8.5",
+ "rand 0.8.6",
"smallvec",
"zeroize",
]
@@ -2968,9 +4430,9 @@ dependencies = [
[[package]]
name = "num-conv"
-version = "0.1.0"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
@@ -3013,11 +4475,21 @@ dependencies = [
"libm",
]
+[[package]]
+name = "num_cpus"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
[[package]]
name = "num_enum"
-version = "0.7.5"
+version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
dependencies = [
"num_enum_derive",
"rustversion",
@@ -3025,23 +4497,122 @@ dependencies = [
[[package]]
name = "num_enum_derive"
-version = "0.7.5"
+version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "num_threads"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "nutype-enum"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1e13adea6de269faa0724df58f43f6fe2a81af7094f1dcb8b5b968eb2103cb3"
+dependencies = [
+ "scuffle-workspace-hack",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags",
+ "block2",
+ "dispatch2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-core-wlan"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48"
+dependencies = [
+ "bitflags",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-security",
+ "objc2-security-foundation",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags",
+ "block2",
+ "libc",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-security"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
+dependencies = [
+ "bitflags",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-security-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
]
[[package]]
-name = "nutype-enum"
-version = "0.1.5"
+name = "objc2-system-configuration"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1e13adea6de269faa0724df58f43f6fe2a81af7094f1dcb8b5b968eb2103cb3"
+checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
- "scuffle-workspace-hack",
+ "bitflags",
+ "dispatch2",
+ "libc",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-security",
]
[[package]]
@@ -3053,6 +4624,12 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "octets"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8311fa8ab7a57759b4ff1f851a3048d9ef0effaa0130726426b742d26d8a88e7"
+
[[package]]
name = "oid-registry"
version = "0.8.1"
@@ -3064,9 +4641,9 @@ dependencies = [
[[package]]
name = "once_cell"
-version = "1.21.3"
+version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"critical-section",
"portable-atomic",
@@ -3078,11 +4655,83 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "openssl-probe"
-version = "0.2.0"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "opentelemetry"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "js-sys",
+ "pin-project-lite",
+ "thiserror 2.0.18",
+ "tracing",
+]
+
+[[package]]
+name = "opentelemetry-proto"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
+dependencies = [
+ "base64 0.22.1",
+ "const-hex",
+ "opentelemetry",
+ "opentelemetry_sdk",
+ "prost",
+ "serde",
+ "serde_json",
+ "tonic",
+ "tonic-prost",
+]
+
+[[package]]
+name = "opentelemetry_sdk"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391"
+checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
+dependencies = [
+ "futures-channel",
+ "futures-executor",
+ "futures-util",
+ "opentelemetry",
+ "percent-encoding",
+ "rand 0.9.4",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "option-operations"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c26d27bb1aeab65138e4bf7666045169d1717febcc9ff870166be8348b223d0"
+dependencies = [
+ "paste",
+]
[[package]]
name = "p256"
@@ -3108,6 +4757,16 @@ dependencies = [
"sha2 0.10.9",
]
+[[package]]
+name = "papaya"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7"
+dependencies = [
+ "equivalent",
+ "seize",
+]
+
[[package]]
name = "parking"
version = "2.2.1"
@@ -3145,6 +4804,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+[[package]]
+name = "pastey"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
+
[[package]]
name = "pem"
version = "3.0.6"
@@ -3186,7 +4851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
dependencies = [
"fixedbitset",
- "indexmap 2.13.0",
+ "indexmap 2.14.0",
]
[[package]]
@@ -3201,66 +4866,29 @@ dependencies = [
[[package]]
name = "pin-project"
-version = "1.1.10"
+version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
-version = "1.1.10"
+version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
name = "pin-project-lite"
-version = "0.2.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
-
-[[package]]
-name = "pin-utils"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
-
-[[package]]
-name = "pkarr"
-version = "5.0.2"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1d346b545765a0ef58b6a7e160e17ddaa7427f439b7b9a287df6c88c9e04bf2"
-dependencies = [
- "async-compat",
- "base32",
- "bytes",
- "cfg_aliases",
- "document-features",
- "dyn-clone",
- "ed25519-dalek",
- "futures-buffered",
- "futures-lite",
- "getrandom 0.3.4",
- "log",
- "lru",
- "ntimestamp",
- "reqwest",
- "self_cell",
- "serde",
- "sha1_smol",
- "simple-dns",
- "thiserror 2.0.17",
- "tokio",
- "tracing",
- "url",
- "wasm-bindgen-futures",
-]
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs1"
@@ -3285,52 +4913,87 @@ dependencies = [
[[package]]
name = "pkcs8"
-version = "0.11.0-rc.8"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
+dependencies = [
+ "der 0.8.0",
+ "spki 0.8.0",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "plist"
+version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77089aec8290d0b7bb01b671b091095cf1937670725af4fd73d47249f03b12c0"
+checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
- "der 0.8.0-rc.10",
- "spki 0.8.0-rc.4",
+ "base64 0.22.1",
+ "indexmap 2.14.0",
+ "quick-xml",
+ "serde",
+ "time",
]
[[package]]
-name = "poly1305"
-version = "0.9.0-rc.2"
+name = "pollster"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+
+[[package]]
+name = "polyval"
+version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fb78a635f75d76d856374961deecf61031c0b6f928c83dc9c0924ab6c019c298"
+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
- "cpufeatures",
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
-version = "1.13.0"
+version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+dependencies = [
+ "serde",
+]
[[package]]
name = "portmapper"
-version = "0.12.0"
+version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7b575f975dcf03e258b0c7ab3f81497d7124f508884c37da66a7314aa2a8d467"
+checksum = "aec2a8809e3f7dba624776bb223da9fed49c413c60b3bef21aadcb67a5e35944"
dependencies = [
"base64 0.22.1",
"bytes",
"derive_more",
- "futures-lite",
- "futures-util",
"hyper-util",
"igd-next",
"iroh-metrics",
"libc",
"n0-error",
+ "n0-future",
"netwatch",
"num_enum",
- "rand 0.9.2",
+ "rand 0.10.1",
"serde",
"smallvec",
- "socket2 0.6.1",
+ "socket2",
"time",
"tokio",
"tokio-util",
@@ -3340,111 +5003,327 @@ dependencies = [
]
[[package]]
-name = "postcard"
-version = "1.1.3"
+name = "postcard"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
+dependencies = [
+ "cobs",
+ "embedded-io 0.4.0",
+ "embedded-io 0.6.1",
+ "postcard-derive",
+ "serde",
+]
+
+[[package]]
+name = "postcard-derive"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "prefix-trie"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7"
+dependencies = [
+ "either",
+ "ipnet",
+ "num-traits",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "primal-check"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
+dependencies = [
+ "num-integer",
+]
+
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit 0.25.12+spec-1.1.0",
+]
+
+[[package]]
+name = "proc-macro-error-attr2"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "proc-macro-error2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
+dependencies = [
+ "proc-macro-error-attr2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "procfs"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f"
+dependencies = [
+ "bitflags",
+ "hex",
+ "procfs-core",
+ "rustix 0.38.44",
+]
+
+[[package]]
+name = "procfs-core"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec"
+dependencies = [
+ "bitflags",
+ "hex",
+]
+
+[[package]]
+name = "prometheus"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a"
+dependencies = [
+ "cfg-if",
+ "fnv",
+ "lazy_static",
+ "libc",
+ "memchr",
+ "parking_lot",
+ "procfs",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "prometheus-client"
+version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
+checksum = "83cd1b99916654a69008fd66b4f9397fbe08e6e51dfe23d4417acf5d3b8cb87c"
dependencies = [
- "cobs",
- "embedded-io 0.4.0",
- "embedded-io 0.6.1",
- "heapless",
- "postcard-derive",
- "serde",
+ "dtoa",
+ "itoa",
+ "parking_lot",
+ "prometheus-client-derive-text-encode",
]
[[package]]
-name = "postcard-derive"
-version = "0.2.2"
+name = "prometheus-client-derive-text-encode"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb"
+checksum = "66a455fbcb954c1a7decf3c586e860fd7889cddf4b8e164be736dbac95a953cd"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 1.0.109",
]
[[package]]
-name = "potential_utf"
-version = "0.1.4"
+name = "prometools"
+version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+checksum = "06c0f1b9189e7361a3e32ded803ab2ecb9a829d86cc32ce3fba8cff1b0cd4b69"
dependencies = [
- "zerovec",
+ "itoa",
+ "parking_lot",
+ "prometheus-client",
+ "ryu",
+ "serde",
]
[[package]]
-name = "powerfmt"
-version = "0.2.0"
+name = "proptest"
+version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+dependencies = [
+ "bitflags",
+ "num-traits",
+ "rand 0.9.4",
+ "rand_chacha 0.9.0",
+ "rand_xorshift",
+ "regex-syntax",
+ "unarray",
+]
[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
+name = "prost"
+version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
- "zerocopy",
+ "bytes",
+ "prost-derive",
]
[[package]]
-name = "primeorder"
-version = "0.13.6"
+name = "prost-derive"
+version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
- "elliptic-curve",
+ "anyhow",
+ "itertools 0.14.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
-name = "proc-macro-crate"
-version = "3.4.0"
+name = "prost-types"
+version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
+checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
dependencies = [
- "toml_edit",
+ "prost",
]
[[package]]
-name = "proc-macro2"
-version = "1.0.105"
+name = "qlog"
+version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
+checksum = "0f15b83c59e6b945f2261c95a1dd9faf239187f32ff0a96af1d1d28c4557f919"
dependencies = [
- "unicode-ident",
+ "serde",
+ "serde_json",
+ "serde_with",
+ "smallvec",
]
[[package]]
-name = "prost"
-version = "0.14.1"
+name = "qmux"
+version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d"
+checksum = "1a625edac9a3021654a955444ca602b7b66b6764c7372196df08af53779ffbe7"
dependencies = [
"bytes",
- "prost-derive",
+ "futures",
+ "rustls",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-rustls",
+ "tokio-tungstenite 0.28.0",
+ "tracing",
+ "web-transport-proto",
+ "web-transport-trait",
]
[[package]]
-name = "prost-derive"
-version = "0.14.1"
+name = "quanta"
+version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425"
+checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
- "anyhow",
- "itertools",
- "proc-macro2",
- "quote",
- "syn",
+ "crossbeam-utils",
+ "libc",
+ "once_cell",
+ "raw-cpuid",
+ "wasi",
+ "web-sys",
+ "winapi",
]
[[package]]
-name = "prost-types"
-version = "0.14.1"
+name = "quiche"
+version = "0.24.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72"
+checksum = "ba053386c3a3b16441cb8a34574f91a9178fd3d813c0639514fcd8e4f274a44b"
dependencies = [
- "prost",
+ "boring",
+ "cmake",
+ "either",
+ "enum_dispatch",
+ "foreign-types-shared",
+ "intrusive-collections",
+ "libc",
+ "libm",
+ "log",
+ "octets",
+ "qlog",
+ "slab",
+ "smallvec",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.39.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+dependencies = [
+ "memchr",
]
[[package]]
@@ -3460,8 +5339,8 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
- "socket2 0.6.1",
- "thiserror 2.0.17",
+ "socket2",
+ "thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
@@ -3469,23 +5348,23 @@ dependencies = [
[[package]]
name = "quinn-proto"
-version = "0.11.13"
+version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"aws-lc-rs",
"bytes",
"fastbloom",
"getrandom 0.3.4",
"lru-slab",
- "rand 0.9.2",
+ "rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier 0.6.2",
"slab",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
@@ -3500,16 +5379,16 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2 0.6.1",
+ "socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
-version = "1.0.43"
+version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -3520,25 +5399,41 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "rand"
-version = "0.8.5"
+version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
- "libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
-version = "0.9.2"
+version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
- "rand_core 0.9.3",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.2",
+ "rand_core 0.10.1",
]
[[package]]
@@ -3558,7 +5453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
- "rand_core 0.9.3",
+ "rand_core 0.9.5",
]
[[package]]
@@ -3567,30 +5462,85 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
- "getrandom 0.2.16",
+ "getrandom 0.2.17",
]
[[package]]
name = "rand_core"
-version = "0.9.3"
+version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rand_xorshift"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+dependencies = [
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "raw-cpuid"
+version = "11.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
+dependencies = [
+ "bitflags",
+]
+
[[package]]
name = "rcgen"
-version = "0.14.6"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
+dependencies = [
+ "pem",
+ "ring",
+ "rustls-pki-types",
+ "time",
+ "yasna 0.5.2",
+]
+
+[[package]]
+name = "rcgen"
+version = "0.14.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ec0a99f2de91c3cddc84b37e7db80e4d96b743e05607f647eb236fc0455907f"
+checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"time",
"x509-parser",
- "yasna",
+ "yasna 0.6.0",
+]
+
+[[package]]
+name = "realfft"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
+dependencies = [
+ "rustfft",
]
[[package]]
@@ -3619,14 +5569,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
name = "regex"
-version = "1.12.2"
+version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
@@ -3636,9 +5586,9 @@ dependencies = [
[[package]]
name = "regex-automata"
-version = "0.4.13"
+version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
@@ -3647,9 +5597,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
-version = "0.8.8"
+version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
@@ -3660,7 +5610,6 @@ dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
- "futures-util",
"http",
"http-body",
"http-body-util",
@@ -3680,6 +5629,42 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "reqwest"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier 0.7.0",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3689,7 +5674,21 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
- "webpki-roots 1.0.5",
+]
+
+[[package]]
+name = "reqwest-middleware"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "http",
+ "reqwest 0.12.28",
+ "serde",
+ "thiserror 1.0.69",
+ "tower-service",
]
[[package]]
@@ -3716,7 +5715,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
- "getrandom 0.2.16",
+ "getrandom 0.2.17",
"libc",
"untrusted 0.9.0",
"windows-sys 0.52.0",
@@ -3742,17 +5741,29 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rubato"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5258099699851cfd0082aeb645feb9c084d9a5e1f1b8d5372086b989fc5e56a1"
+dependencies = [
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "realfft",
+]
+
[[package]]
name = "rustc-demangle"
-version = "0.1.26"
+version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
+checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]]
name = "rustc-hash"
-version = "2.1.1"
+version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
@@ -3763,6 +5774,20 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rustfft"
+version = "6.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
+dependencies = [
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "primal-check",
+ "strength_reduce",
+ "transpose",
+]
+
[[package]]
name = "rusticata-macros"
version = "4.1.0"
@@ -3774,22 +5799,35 @@ dependencies = [
[[package]]
name = "rustix"
-version = "1.1.3"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
- "linux-raw-sys",
+ "linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
-version = "0.23.36"
+version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"log",
@@ -3813,20 +5851,11 @@ dependencies = [
"security-framework",
]
-[[package]]
-name = "rustls-pemfile"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
-dependencies = [
- "rustls-pki-types",
-]
-
[[package]]
name = "rustls-pki-types"
-version = "1.13.2"
+version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
@@ -3834,13 +5863,13 @@ dependencies = [
[[package]]
name = "rustls-platform-verifier"
-version = "0.5.3"
+version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1"
+checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
- "jni",
+ "jni 0.21.1",
"log",
"once_cell",
"rustls",
@@ -3849,19 +5878,19 @@ dependencies = [
"rustls-webpki",
"security-framework",
"security-framework-sys",
- "webpki-root-certs 0.26.11",
- "windows-sys 0.59.0",
+ "webpki-root-certs",
+ "windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier"
-version = "0.6.2"
+version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
+checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
- "jni",
+ "jni 0.22.4",
"log",
"once_cell",
"rustls",
@@ -3870,7 +5899,7 @@ dependencies = [
"rustls-webpki",
"security-framework",
"security-framework-sys",
- "webpki-root-certs 1.0.5",
+ "webpki-root-certs",
"windows-sys 0.61.2",
]
@@ -3882,9 +5911,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
-version = "0.103.8"
+version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
@@ -3893,26 +5922,16 @@ dependencies = [
]
[[package]]
-name = "rustversion"
-version = "1.0.22"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
-
-[[package]]
-name = "ryu"
+name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
-name = "salsa20"
-version = "0.11.0-rc.1"
+name = "ryu"
+version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3ff3b81c8a6e381bc1673768141383f9328048a60edddcfc752a8291a138443"
-dependencies = [
- "cfg-if",
- "cipher",
-]
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
@@ -3925,9 +5944,9 @@ dependencies = [
[[package]]
name = "schannel"
-version = "0.1.28"
+version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
@@ -3946,9 +5965,9 @@ dependencies = [
[[package]]
name = "schemars"
-version = "1.2.0"
+version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2"
+checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"dyn-clone",
"ref-cast",
@@ -3968,6 +5987,38 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "scroll"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
+dependencies = [
+ "scroll_derive",
+]
+
+[[package]]
+name = "scroll_derive"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "scuffle-av1"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "028eddc8b17fe9dba817b238c56d3acf03748bdbed4c35783cfb93857ef15955"
+dependencies = [
+ "byteorder",
+ "bytes",
+ "scuffle-bytes-util",
+ "scuffle-workspace-hack",
+]
+
[[package]]
name = "scuffle-bytes-util"
version = "0.1.5"
@@ -4013,9 +6064,9 @@ checksum = "8028ded836a0d9fabdfa4d713389b76a2098b5153f50a135c8faed7e3a3d5ae2"
[[package]]
name = "sd-notify"
-version = "0.4.5"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b943eadf71d8b69e661330cb0e2656e31040acf21ee7708e2c238a0ec6af2bf4"
+checksum = "3e4ef7359e694bfaf1dd27a30f9d760b54c00dfae9f19bd0c05a39bc9128fe76"
dependencies = [
"libc",
]
@@ -4036,9 +6087,9 @@ dependencies = [
[[package]]
name = "security-framework"
-version = "3.5.1"
+version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation 0.10.1",
@@ -4049,25 +6100,33 @@ dependencies = [
[[package]]
name = "security-framework-sys"
-version = "2.15.0"
+version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
-name = "self_cell"
-version = "1.2.2"
+name = "seize"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
+checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
[[package]]
name = "semver"
-version = "1.0.27"
+version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
[[package]]
name = "send_wrapper"
@@ -4112,15 +6171,16 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
name = "serde_json"
-version = "1.0.149"
+version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
+ "indexmap 2.14.0",
"itoa",
"memchr",
"serde",
@@ -4141,9 +6201,18 @@ dependencies = [
[[package]]
name = "serde_spanned"
-version = "1.0.4"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
@@ -4162,17 +6231,18 @@ dependencies = [
[[package]]
name = "serde_with"
-version = "3.16.1"
+version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7"
+checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
dependencies = [
"base64 0.22.1",
+ "bs58",
"chrono",
"hex",
"indexmap 1.9.3",
- "indexmap 2.13.0",
+ "indexmap 2.14.0",
"schemars 0.9.0",
- "schemars 1.2.0",
+ "schemars 1.2.1",
"serde_core",
"serde_json",
"serde_with_macros",
@@ -4181,46 +6251,58 @@ dependencies = [
[[package]]
name = "serde_with_macros"
-version = "3.16.1"
+version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c"
+checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
dependencies = [
- "darling",
+ "darling 0.23.0",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_yaml"
+version = "0.8.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b"
+dependencies = [
+ "indexmap 1.9.3",
+ "ryu",
+ "serde",
+ "yaml-rust",
]
[[package]]
name = "serdect"
-version = "0.4.2"
+version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9af4a3e75ebd5599b30d4de5768e00b5095d518a79fefc3ecbaf77e665d1ec06"
+checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
dependencies = [
"base16ct 1.0.0",
"serde",
]
[[package]]
-name = "sha1"
-version = "0.10.6"
+name = "sfv"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+checksum = "0d471eaefb14f4b30032525bdb124b36e55ba9cb1292080e06f1a236cd10fe87"
dependencies = [
- "cfg-if",
- "cpufeatures",
- "digest 0.10.7",
+ "base64 0.22.1",
+ "indexmap 2.14.0",
+ "ref-cast",
]
[[package]]
name = "sha1"
-version = "0.11.0-rc.2"
+version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c5e046edf639aa2e7afb285589e5405de2ef7e61d4b0ac1e30256e3eab911af9"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
- "cpufeatures",
- "digest 0.11.0-rc.3",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
]
[[package]]
@@ -4236,19 +6318,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
-version = "0.11.0-rc.2"
+version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
- "cpufeatures",
- "digest 0.11.0-rc.3",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
]
[[package]]
@@ -4266,6 +6348,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -4288,15 +6376,25 @@ dependencies = [
[[package]]
name = "signature"
-version = "3.0.0-rc.6"
+version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "597a96996ccff7dfa16f052bd995b4cecc72af22c35138738dc029f0ead6608d"
+checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
[[package]]
name = "simd-adler32"
-version = "0.3.8"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "simd_cesu8"
+version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
[[package]]
name = "simdutf8"
@@ -4306,63 +6404,140 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simple-dns"
-version = "0.9.3"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a"
+checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4"
dependencies = [
"bitflags",
]
[[package]]
name = "simple_asn1"
-version = "0.6.3"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
+checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"time",
]
[[package]]
name = "siphasher"
-version = "1.0.1"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
-version = "0.4.11"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slog"
+version = "2.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b3b8565691b22d2bdfc066426ed48f837fc0c5f2c8cad8d9718f7f99d6995c1"
+dependencies = [
+ "anyhow",
+ "erased-serde 0.3.31",
+ "rustversion",
+ "serde_core",
+]
+
+[[package]]
+name = "slog-async"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72c8038f898a2c79507940990f05386455b3a317d8f18d4caea7cbc3d5096b84"
+dependencies = [
+ "crossbeam-channel",
+ "slog",
+ "take_mut",
+ "thread_local",
+]
+
+[[package]]
+name = "slog-json"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e1e53f61af1e3c8b852eef0a9dee29008f55d6dd63794f3f12cef786cf0f219"
+dependencies = [
+ "serde",
+ "serde_json",
+ "slog",
+ "time",
+]
+
+[[package]]
+name = "slog-scope"
+version = "4.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b76cf645c92e7850d5a1c9205ebf2864bd32c0ab3e978e6daad51fedf7ef54"
+dependencies = [
+ "arc-swap",
+ "lazy_static",
+ "slog",
+]
+
+[[package]]
+name = "slog-stdlog"
+version = "4.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
+checksum = "6706b2ace5bbae7291d3f8d2473e2bfab073ccd7d03670946197aec98471fa3e"
+dependencies = [
+ "log",
+ "slog",
+ "slog-scope",
+]
+
+[[package]]
+name = "slog-term"
+version = "2.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cb1fc680b38eed6fad4c02b3871c09d2c81db8c96aa4e9c0a34904c830f09b5"
+dependencies = [
+ "chrono",
+ "is-terminal",
+ "slog",
+ "term",
+ "thread_local",
+ "time",
+]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+dependencies = [
+ "serde",
+]
[[package]]
-name = "socket2"
-version = "0.5.10"
+name = "smawk"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
-dependencies = [
- "libc",
- "windows-sys 0.52.0",
-]
+checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]]
name = "socket2"
-version = "0.6.1"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
+[[package]]
+name = "sorted-index-buffer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06"
+
[[package]]
name = "spez"
version = "0.1.2"
@@ -4371,7 +6546,7 @@ checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -4389,6 +6564,15 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
+[[package]]
+name = "spinning_top"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
+dependencies = [
+ "lock_api",
+]
+
[[package]]
name = "spki"
version = "0.7.3"
@@ -4401,12 +6585,12 @@ dependencies = [
[[package]]
name = "spki"
-version = "0.8.0-rc.4"
+version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8baeff88f34ed0691978ec34440140e1572b68c7dd4a495fd14a3dc1944daa80"
+checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
dependencies = [
"base64ct",
- "der 0.8.0-rc.10",
+ "der 0.8.0",
]
[[package]]
@@ -4415,6 +6599,18 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "strength_reduce"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -4423,23 +6619,23 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
-version = "0.27.2"
+version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
-version = "0.27.2"
+version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -4450,9 +6646,20 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
-version = "2.0.114"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -4476,14 +6683,14 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
name = "system-configuration"
-version = "0.6.1"
+version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags",
"core-foundation 0.9.4",
@@ -4500,25 +6707,79 @@ dependencies = [
"libc",
]
+[[package]]
+name = "system-deps"
+version = "7.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
+dependencies = [
+ "cfg-expr",
+ "heck",
+ "pkg-config",
+ "toml 1.1.2+spec-1.1.0",
+ "version-compare",
+]
+
[[package]]
name = "tagptr"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
+[[package]]
+name = "take_mut"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60"
+
+[[package]]
+name = "target-lexicon"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
+
+[[package]]
+name = "task-killswitch"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b2ba744ef0fcfe2d3fa7475ec61af0a9c76c1b0a75d58f0d20a9e10745ae44f"
+dependencies = [
+ "dashmap",
+ "parking_lot",
+ "tokio",
+]
+
[[package]]
name = "tempfile"
-version = "3.24.0"
+version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.2",
"once_cell",
- "rustix",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "term"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1"
+dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "textwrap"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
+dependencies = [
+ "smawk",
+]
+
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -4530,11 +6791,11 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "2.0.17"
+version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
- "thiserror-impl 2.0.17",
+ "thiserror-impl 2.0.18",
]
[[package]]
@@ -4545,18 +6806,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
name = "thiserror-impl"
-version = "2.0.17"
+version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -4568,33 +6829,76 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "thrift_codec"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83d957f535b242b91aa9f47bde08080f9a6fef276477e55b0079979d002759d5"
+dependencies = [
+ "byteorder",
+ "trackable",
+]
+
+[[package]]
+name = "tikv-jemalloc-ctl"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "661f1f6a57b3a36dc9174a2c10f19513b4866816e13425d3e418b11cc37bc24c"
+dependencies = [
+ "libc",
+ "paste",
+ "tikv-jemalloc-sys",
+]
+
+[[package]]
+name = "tikv-jemalloc-sys"
+version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "tikv-jemallocator"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a"
+dependencies = [
+ "libc",
+ "tikv-jemalloc-sys",
+]
+
[[package]]
name = "time"
-version = "0.3.44"
+version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"js-sys",
+ "libc",
"num-conv",
+ "num_threads",
"powerfmt",
- "serde",
+ "serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
-version = "0.1.6"
+version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
-version = "0.2.24"
+version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
@@ -4602,9 +6906,9 @@ dependencies = [
[[package]]
name = "tinystr"
-version = "0.8.2"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
@@ -4612,9 +6916,9 @@ dependencies = [
[[package]]
name = "tinyvec"
-version = "1.10.0"
+version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
@@ -4627,9 +6931,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.49.0"
+version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
@@ -4637,7 +6941,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
- "socket2 0.6.1",
+ "socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
@@ -4645,13 +6949,48 @@ dependencies = [
[[package]]
name = "tokio-macros"
-version = "2.6.0"
+version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tokio-quiche"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3152843e8aafb0dae1f31cb0e1223ffff010de71efca2ec2a6b5405a57001a5"
+dependencies = [
+ "anyhow",
+ "boring",
+ "buffer-pool",
+ "crossbeam",
+ "datagram-socket",
+ "foundations",
+ "futures",
+ "futures-util",
+ "ipnetwork",
+ "libc",
+ "log",
+ "nix",
+ "octets",
+ "pin-project",
+ "quiche",
+ "serde",
+ "serde_with",
+ "slog-scope",
+ "slog-stdlog",
+ "smallvec",
+ "task-killswitch",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-stream",
+ "tokio-util",
+ "triomphe",
+ "url",
]
[[package]]
@@ -4678,30 +7017,30 @@ dependencies = [
[[package]]
name = "tokio-tungstenite"
-version = "0.24.0"
+version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
+checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"rustls",
+ "rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls",
- "tungstenite 0.24.0",
- "webpki-roots 0.26.11",
+ "tungstenite 0.28.0",
]
[[package]]
name = "tokio-tungstenite"
-version = "0.28.0"
+version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
- "tungstenite 0.28.0",
+ "tungstenite 0.29.0",
]
[[package]]
@@ -4712,28 +7051,31 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
+ "futures-io",
"futures-sink",
"futures-util",
"pin-project-lite",
+ "slab",
"tokio",
]
[[package]]
name = "tokio-websockets"
-version = "0.12.3"
+version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1b6348ebfaaecd771cecb69e832961d277f59845d4220a584701f72728152b7"
+checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb"
dependencies = [
+ "aws-lc-rs",
"base64 0.22.1",
"bytes",
"futures-core",
"futures-sink",
- "getrandom 0.3.4",
+ "getrandom 0.4.2",
"http",
"httparse",
- "rand 0.9.2",
- "ring",
+ "rand 0.10.1",
"rustls-pki-types",
+ "sha1_smol",
"simdutf8",
"tokio",
"tokio-rustls",
@@ -4742,17 +7084,53 @@ dependencies = [
[[package]]
name = "toml"
-version = "0.9.11+spec-1.1.0"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.11",
+ "toml_edit 0.22.27",
+]
+
+[[package]]
+name = "toml"
+version = "0.9.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
+checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
- "indexmap 2.13.0",
+ "indexmap 2.14.0",
"serde_core",
- "serde_spanned",
- "toml_datetime",
+ "serde_spanned 1.1.1",
+ "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
- "winnow",
+ "winnow 1.0.3",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
]
[[package]]
@@ -4764,38 +7142,67 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
[[package]]
name = "toml_edit"
-version = "0.23.10+spec-1.0.0"
+version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
- "indexmap 2.13.0",
- "toml_datetime",
+ "indexmap 2.14.0",
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.11",
+ "toml_write",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
+dependencies = [
+ "indexmap 2.14.0",
+ "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
- "winnow",
+ "winnow 1.0.3",
]
[[package]]
name = "toml_parser"
-version = "1.0.6+spec-1.1.0"
+version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
- "winnow",
+ "winnow 1.0.3",
]
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
[[package]]
name = "toml_writer"
-version = "1.0.6+spec-1.1.0"
+version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
+checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tonic"
-version = "0.14.2"
+version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203"
+checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [
"async-trait",
"axum",
@@ -4810,7 +7217,7 @@ dependencies = [
"hyper-util",
"percent-encoding",
"pin-project",
- "socket2 0.6.1",
+ "socket2",
"sync_wrapper",
"tokio",
"tokio-stream",
@@ -4822,9 +7229,9 @@ dependencies = [
[[package]]
name = "tonic-prost"
-version = "0.14.2"
+version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67"
+checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
dependencies = [
"bytes",
"prost",
@@ -4833,13 +7240,13 @@ dependencies = [
[[package]]
name = "tower"
-version = "0.5.2"
+version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
- "indexmap 2.13.0",
+ "indexmap 2.14.0",
"pin-project-lite",
"slab",
"sync_wrapper",
@@ -4852,9 +7259,9 @@ dependencies = [
[[package]]
name = "tower-http"
-version = "0.6.8"
+version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"async-compression",
"bitflags",
@@ -4866,7 +7273,6 @@ dependencies = [
"http-body-util",
"http-range-header",
"httpdate",
- "iri-string",
"mime",
"mime_guess",
"percent-encoding",
@@ -4876,7 +7282,7 @@ dependencies = [
"tower",
"tower-layer",
"tower-service",
- "tracing",
+ "url",
]
[[package]]
@@ -4903,6 +7309,17 @@ dependencies = [
"tracing-core",
]
+[[package]]
+name = "tracing-android"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12612be8f868a09c0ceae7113ff26afe79d81a24473a393cb9120ece162e86c0"
+dependencies = [
+ "android_log-sys",
+ "tracing",
+ "tracing-subscriber",
+]
+
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@@ -4911,7 +7328,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -4937,9 +7354,9 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
-version = "0.3.22"
+version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
@@ -4953,6 +7370,66 @@ dependencies = [
"tracing-log",
]
+[[package]]
+name = "tracing-test"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051"
+dependencies = [
+ "tracing-core",
+ "tracing-subscriber",
+ "tracing-test-macro",
+]
+
+[[package]]
+name = "tracing-test-macro"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "trackable"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b15bd114abb99ef8cee977e517c8f37aee63f184f2d08e3e6ceca092373369ae"
+dependencies = [
+ "trackable_derive",
+]
+
+[[package]]
+name = "trackable_derive"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebeb235c5847e2f82cfe0f07eb971d1e5f6804b18dac2ae16349cc604380f82f"
+dependencies = [
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "transpose"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
+dependencies = [
+ "num-integer",
+ "strength_reduce",
+]
+
+[[package]]
+name = "triomphe"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39"
+dependencies = [
+ "serde",
+ "stable_deref_trait",
+]
+
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -4961,46 +7438,56 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
-version = "0.24.0"
+version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
+checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
- "byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
- "rand 0.8.5",
+ "rand 0.9.4",
"rustls",
"rustls-pki-types",
- "sha1 0.10.6",
- "thiserror 1.0.69",
+ "sha1",
+ "thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "tungstenite"
-version = "0.28.0"
+version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
- "rand 0.9.2",
- "sha1 0.10.6",
- "thiserror 2.0.17",
- "utf-8",
+ "rand 0.9.4",
+ "sha1",
+ "thiserror 2.0.18",
]
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
[[package]]
name = "typenum"
-version = "1.19.0"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unarray"
+version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicase"
@@ -5010,15 +7497,15 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
-version = "1.0.22"
+version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
-version = "1.12.0"
+version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-xid"
@@ -5026,16 +7513,160 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "uniffi"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc5f2297ee5b893405bed1a6929faec4713a061df158ecf5198089f23910d470"
+dependencies = [
+ "anyhow",
+ "camino",
+ "cargo_metadata",
+ "clap",
+ "uniffi_bindgen",
+ "uniffi_build",
+ "uniffi_core",
+ "uniffi_macros",
+ "uniffi_pipeline",
+]
+
+[[package]]
+name = "uniffi_bindgen"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bc0c60a9607e7ab77a2ad47ec5530178015014839db25af7512447d2238016c"
+dependencies = [
+ "anyhow",
+ "askama",
+ "camino",
+ "cargo_metadata",
+ "fs-err 2.11.0",
+ "glob",
+ "goblin",
+ "heck",
+ "indexmap 2.14.0",
+ "once_cell",
+ "serde",
+ "tempfile",
+ "textwrap",
+ "toml 0.9.12+spec-1.1.0",
+ "uniffi_internal_macros",
+ "uniffi_meta",
+ "uniffi_pipeline",
+ "uniffi_udl",
+]
+
+[[package]]
+name = "uniffi_build"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c39413c43b955e4aa8a4e2b34bbd1b6b5ff6bd85532b52f9eb92fbe88c14458"
+dependencies = [
+ "anyhow",
+ "camino",
+ "uniffi_bindgen",
+]
+
+[[package]]
+name = "uniffi_core"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77baf5d539fe2e1ad6805e942dbc5dbdeb2b83eb5f2b3a6535d422ca4b02a12f"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "once_cell",
+ "static_assertions",
+]
+
+[[package]]
+name = "uniffi_internal_macros"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5"
+dependencies = [
+ "anyhow",
+ "indexmap 2.14.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "uniffi_macros"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9273ec45330d8fe9a3701b7b983cea7a4e218503359831967cb95d26b873561"
+dependencies = [
+ "camino",
+ "fs-err 2.11.0",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 2.0.117",
+ "toml 0.9.12+spec-1.1.0",
+ "uniffi_meta",
+]
+
+[[package]]
+name = "uniffi_meta"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "431d2f443e7828a6c29d188de98b6771a6491ee98bba2d4372643bf93f988a18"
+dependencies = [
+ "anyhow",
+ "siphasher",
+ "uniffi_internal_macros",
+ "uniffi_pipeline",
+]
+
+[[package]]
+name = "uniffi_pipeline"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap 2.14.0",
+ "tempfile",
+ "uniffi_internal_macros",
+]
+
+[[package]]
+name = "uniffi_udl"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68773ec0e1c067b6505a73bbf6a5782f31a7f9209333a0df97b87565c46bf370"
+dependencies = [
+ "anyhow",
+ "textwrap",
+ "uniffi_meta",
+ "weedle2",
+]
+
[[package]]
name = "universal-hash"
-version = "0.6.0-rc.2"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a55be643b40a21558f44806b53ee9319595bc7ca6896372e4e08e5d7d83c9cd6"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
- "crypto-common 0.2.0-rc.4",
+ "crypto-common 0.1.7",
"subtle",
]
+[[package]]
+name = "unsafe-libopus"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c757408e7256c910343c7b215c8c8b878948630eecc774baabcca0972376eb73"
+dependencies = [
+ "arrayref",
+ "num-complex",
+ "num-traits",
+]
+
[[package]]
name = "untrusted"
version = "0.7.1"
@@ -5081,20 +7712,69 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.19.0"
+version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
+checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
dependencies = [
- "getrandom 0.3.4",
+ "getrandom 0.4.2",
"js-sys",
"wasm-bindgen",
]
[[package]]
-name = "valuable"
-version = "0.1.1"
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "vergen"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75"
+dependencies = [
+ "anyhow",
+ "derive_builder",
+ "rustversion",
+ "vergen-lib",
+]
+
+[[package]]
+name = "vergen-gitcl"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9"
+dependencies = [
+ "anyhow",
+ "derive_builder",
+ "rustversion",
+ "time",
+ "vergen",
+ "vergen-lib",
+]
+
+[[package]]
+name = "vergen-lib"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569"
+dependencies = [
+ "anyhow",
+ "derive_builder",
+ "rustversion",
+]
+
+[[package]]
+name = "version-compare"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "version_check"
@@ -5129,18 +7809,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
-version = "1.0.1+wasi-0.2.4"
+version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
- "wit-bindgen",
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-bindgen"
-version = "0.2.106"
+version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
+checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
dependencies = [
"cfg-if",
"once_cell",
@@ -5151,22 +7840,19 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.56"
+version = "0.4.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c"
+checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
dependencies = [
- "cfg-if",
"js-sys",
- "once_cell",
"wasm-bindgen",
- "web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.106"
+version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
+checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5174,31 +7860,53 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.106"
+version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
+checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.106"
+version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
+checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
dependencies = [
"unicode-ident",
]
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap 2.14.0",
+ "wasm-encoder",
+ "wasmparser",
+]
+
[[package]]
name = "wasm-streams"
-version = "0.4.2"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
"futures-util",
"js-sys",
@@ -5207,11 +7915,23 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap 2.14.0",
+ "semver",
+]
+
[[package]]
name = "web-async"
-version = "0.1.1"
+version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6b2260b739b0e95cf9b78f22a64704af7ed9760ea12baa3745b4b97899dc89a"
+checksum = "f5414b65d9a5094649bb99987bb74db71febfdfa3677b7954a0a05c99d0424e8"
dependencies = [
"tokio",
"tracing",
@@ -5220,9 +7940,9 @@ dependencies = [
[[package]]
name = "web-sys"
-version = "0.3.83"
+version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
+checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5240,17 +7960,35 @@ dependencies = [
[[package]]
name = "web-transport-iroh"
-version = "0.1.1"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02538319baa3665fbdd788bb2e08c498ad7c17462b9444d71f9da6a5b11247a9"
+checksum = "8f4ebffba98c30a0ac3560c4d7c595e61a5ad040049d65ff5c91304e5ee5f5ee"
dependencies = [
"bytes",
"http",
"iroh",
- "iroh-quinn",
"n0-error",
"n0-future",
- "thiserror 2.0.17",
+ "tokio",
+ "tracing",
+ "url",
+ "web-transport-proto",
+ "web-transport-trait",
+]
+
+[[package]]
+name = "web-transport-noq"
+version = "0.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d42caba209a9583813c1d802197fc568e9a78f45e42ff40fba54e4d16d2e3af2"
+dependencies = [
+ "bytes",
+ "futures",
+ "http",
+ "noq 0.17.0",
+ "rustls",
+ "rustls-native-certs",
+ "thiserror 2.0.18",
"tokio",
"tracing",
"url",
@@ -5260,31 +7998,33 @@ dependencies = [
[[package]]
name = "web-transport-proto"
-version = "0.3.1"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "660175a6d1643adb93b71c4f853d4f20f0fce47f53ae579afe9f7711fe84870d"
+checksum = "0225d295c8ac00a2e9a498aefeaf3f3c6186da12a251c938189b15b82ea22808"
dependencies = [
"bytes",
"http",
- "thiserror 2.0.17",
+ "sfv",
+ "thiserror 2.0.18",
"tokio",
"url",
]
[[package]]
-name = "web-transport-quinn"
-version = "0.10.2"
+name = "web-transport-quiche"
+version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1f44b4e68a3e7adb790793e24ec8b5923a610a8c2df1d6cd58849f9e4759d04"
+checksum = "b165e13c505ce9541d0a1f762020fed6e63d3ee88e6f685e4f60007cfa555282"
dependencies = [
+ "boring",
"bytes",
+ "flume",
"futures",
"http",
- "quinn",
- "rustls",
- "rustls-native-certs",
- "thiserror 2.0.17",
+ "rustls-pki-types",
+ "thiserror 2.0.18",
"tokio",
+ "tokio-quiche",
"tracing",
"url",
"web-transport-proto",
@@ -5292,63 +8032,68 @@ dependencies = [
]
[[package]]
-name = "web-transport-trait"
-version = "0.3.1"
+name = "web-transport-quinn"
+version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ae5c857e6b426610648b39c6b48f9e66ae97b27b166d7c2f1ec369596548271"
+checksum = "cac11b6caf163be7f980442a26fcba15e8074a5f22e85fbb71f0f77d11cecf60"
dependencies = [
"bytes",
+ "futures",
+ "http",
+ "quinn",
+ "rustls",
+ "rustls-native-certs",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "url",
+ "web-transport-proto",
+ "web-transport-trait",
]
[[package]]
-name = "web-transport-ws"
-version = "0.2.2"
+name = "web-transport-trait"
+version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aee7ae0f2a6f3596dc2842e20f2ad0cf046211414979d67e445760348b60becf"
+checksum = "fc5f83d19cf6c8ba147f4e1e5935a8a115c91f6abbf714d740a83b967d558e6e"
dependencies = [
"bytes",
- "futures",
- "thiserror 2.0.17",
- "tokio",
- "tokio-tungstenite 0.24.0",
- "web-transport-proto",
- "web-transport-trait",
]
[[package]]
-name = "webpki-root-certs"
-version = "0.26.11"
+name = "webm-iterable"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e"
+checksum = "cd9fbf173b4b38f2f8bbb0082a0d4cb21f263a70811f5fccb1663c421c66d9f9"
dependencies = [
- "webpki-root-certs 1.0.5",
+ "ebml-iterable",
]
[[package]]
name = "webpki-root-certs"
-version = "1.0.5"
+version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc"
+checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
-version = "0.26.11"
+version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
- "webpki-roots 1.0.5",
+ "rustls-pki-types",
]
[[package]]
-name = "webpki-roots"
-version = "1.0.5"
+name = "weedle2"
+version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
+checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e"
dependencies = [
- "rustls-pki-types",
+ "nom",
]
[[package]]
@@ -5441,7 +8186,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -5452,7 +8197,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -5471,6 +8216,17 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -5498,15 +8254,6 @@ dependencies = [
"windows-targets 0.42.2",
]
-[[package]]
-name = "windows-sys"
-version = "0.48.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
-dependencies = [
- "windows-targets 0.48.5",
-]
-
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -5558,21 +8305,6 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
-[[package]]
-name = "windows-targets"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
-dependencies = [
- "windows_aarch64_gnullvm 0.48.5",
- "windows_aarch64_msvc 0.48.5",
- "windows_i686_gnu 0.48.5",
- "windows_i686_msvc 0.48.5",
- "windows_x86_64_gnu 0.48.5",
- "windows_x86_64_gnullvm 0.48.5",
- "windows_x86_64_msvc 0.48.5",
-]
-
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -5621,12 +8353,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
-
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -5645,12 +8371,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -5669,12 +8389,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
-
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -5705,12 +8419,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
-
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -5729,12 +8437,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -5753,12 +8455,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -5777,12 +8473,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -5797,49 +8487,159 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
-version = "0.7.14"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
+checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
[[package]]
-name = "winreg"
-version = "0.50.0"
+name = "wiremock"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
+checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
- "cfg-if",
- "windows-sys 0.48.0",
+ "assert-json-diff",
+ "base64 0.22.1",
+ "deadpool",
+ "futures",
+ "http",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "log",
+ "once_cell",
+ "regex",
+ "serde",
+ "serde_json",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
-version = "0.46.0"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap 2.14.0",
+ "prettyplease",
+ "syn 2.0.117",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap 2.14.0",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap 2.14.0",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
[[package]]
name = "wmi"
-version = "0.17.3"
+version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "120d8c2b6a7c96c27bf4a7947fd7f02d73ca7f5958b8bd72a696e46cb5521ee6"
+checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64"
dependencies = [
"chrono",
"futures",
"log",
"serde",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"windows",
"windows-core",
]
[[package]]
name = "writeable"
-version = "0.6.2"
+version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "ws_stream_wasm"
@@ -5854,7 +8654,7 @@ dependencies = [
"pharos",
"rustc_version",
"send_wrapper",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
@@ -5862,9 +8662,9 @@ dependencies = [
[[package]]
name = "x509-parser"
-version = "0.18.0"
+version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb3e137310115a65136898d2079f003ce33331a6c4b0d51f1531d1be082b6425"
+checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"aws-lc-rs",
@@ -5875,7 +8675,7 @@ dependencies = [
"oid-registry",
"ring",
"rusticata-macros",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"time",
]
@@ -5894,6 +8694,27 @@ dependencies = [
"xml-rs",
]
+[[package]]
+name = "yaml-merge-keys"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af47d205071caaef70ebce5e04e1d88eba944833f8a6626dacdda700f86c285a"
+dependencies = [
+ "lazy_static",
+ "serde_yaml",
+ "thiserror 1.0.69",
+ "yaml-rust",
+]
+
+[[package]]
+name = "yaml-rust"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+dependencies = [
+ "linked-hash-map",
+]
+
[[package]]
name = "yasna"
version = "0.5.2"
@@ -5903,11 +8724,21 @@ dependencies = [
"time",
]
+[[package]]
+name = "yasna"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
+dependencies = [
+ "bit-vec",
+ "time",
+]
+
[[package]]
name = "yoke"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -5916,60 +8747,54 @@ dependencies = [
[[package]]
name = "yoke-derive"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"synstructure",
]
-[[package]]
-name = "z32"
-version = "1.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f"
-
[[package]]
name = "zerocopy"
-version = "0.8.33"
+version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
+checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.33"
+version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
+checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
name = "zerofrom"
-version = "0.1.6"
+version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
-version = "0.1.6"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"synstructure",
]
@@ -5990,14 +8815,14 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
name = "zerotrie"
-version = "0.2.3"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
@@ -6006,9 +8831,9 @@ dependencies = [
[[package]]
name = "zerovec"
-version = "0.11.5"
+version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
@@ -6017,17 +8842,17 @@ dependencies = [
[[package]]
name = "zerovec-derive"
-version = "0.11.2"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
name = "zmij"
-version = "1.0.12"
+version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
index 4aa705b1fb..fdf84f17e1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,33 +1,80 @@
[workspace]
members = [
"rs/hang",
- "rs/hang-cli",
+ "rs/kio",
"rs/libmoq",
- "rs/moq-clock",
- "rs/moq-lite",
+ "rs/moq-audio",
+ "rs/moq-boy",
+ "rs/moq-cli",
+ "rs/moq-ffi",
+ "rs/moq-gst",
+ "rs/moq-loc",
+ "rs/moq-msf",
+ "rs/moq-mux",
"rs/moq-native",
+ "rs/moq-net",
"rs/moq-relay",
"rs/moq-token",
"rs/moq-token-cli",
+ "rs/moq-video",
+]
+default-members = [
+ "rs/kio",
+ "rs/hang",
+ # "rs/libmoq", # requires C toolchain
+ "rs/moq-boy",
+ "rs/moq-audio",
+ "rs/moq-cli",
+ # "rs/moq-ffi", # requires Python/maturin
+ # "rs/moq-gst", # requires GStreamer
+ "rs/moq-loc",
+ "rs/moq-msf",
+ "rs/moq-net",
+ "rs/moq-mux",
+ "rs/moq-native",
+ "rs/moq-relay",
+ "rs/moq-token",
+ "rs/moq-token-cli",
+ "rs/moq-video",
]
resolver = "2"
+[workspace.package]
+rust-version = "1.85"
+
[workspace.dependencies]
-hang = { version = "0.10", path = "rs/hang" }
-moq-lite = { version = "0.11", path = "rs/moq-lite" }
-moq-native = { version = "0.11", path = "rs/moq-native" }
-moq-token = { version = "0.5", path = "rs/moq-token" }
+hang = { version = "0.18", path = "rs/hang" }
+kio = { version = "0.3", path = "rs/kio" }
+moq-audio = { version = "0.0.1", path = "rs/moq-audio" }
+moq-loc = { version = "0.1", path = "rs/moq-loc" }
+moq-msf = { version = "0.2", path = "rs/moq-msf" }
+moq-mux = { version = "0.5", path = "rs/moq-mux" }
+moq-native = { version = "0.16", path = "rs/moq-native", default-features = false }
+moq-net = { version = "0.1", path = "rs/moq-net" }
+moq-token = { version = "0.6", path = "rs/moq-token" }
+qmux = { version = "0.0.7", default-features = false }
serde = { version = "1", features = ["derive"] }
tokio = "1.48"
-web-async = { version = "0.1.1", features = ["tracing"] }
-web-transport-iroh = "0.1.1"
-web-transport-quinn = "0.10"
-web-transport-trait = "0.3"
-web-transport-ws = "0.2.2"
+web-async = { version = "0.1.3", features = ["tracing"] }
+web-transport-iroh = "0.5"
+web-transport-noq = "0.0.3"
+web-transport-proto = "0.6"
+web-transport-quiche = "0.2"
+web-transport-quinn = "0.11"
+web-transport-trait = "0.3.4"
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
+
+[profile.profiling]
+inherits = "release"
+debug = "line-tables-only"
+# frame pointers: pass RUSTFLAGS="-C force-frame-pointers=yes" when building
+
+[profile.release-with-debug]
+inherits = "release"
+debug = true
diff --git a/README.md b/README.md
index c64a2daf76..252bffddc3 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,8 @@

[](https://discord.gg/FCYF3p99mr)
-[](https://crates.io/crates/moq-lite)
-[](https://www.npmjs.com/package/@moq/lite)
+[](https://crates.io/crates/moq-net)
+[](https://www.npmjs.com/package/@moq/net)
# Media over QUIC
@@ -14,61 +14,73 @@ Built using modern web technologies, MoQ delivers WebRTC-like latency without th
The core networking is delegated to a QUIC library but the rest is in application-space, giving you full control over your media pipeline.
**Key Features:**
-- 🚀 **Real-time latency** using QUIC for priotization and partial reliability.
+
+- 🚀 **Real-time latency** using QUIC for prioritization and partial reliability.
- 📈 **Massive scale** designed for fan-out and supports cross-region clustering.
- 🌐 **Modern Web** using [WebTransport](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API), [WebCodecs](https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API), and [WebAudio](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API).
- 🎯 **Multi-language** with both Rust (native) and TypeScript (web) libraries.
- 🔧 **Generic** for any live data, not just media. Includes text chat as both an example and a core feature.
-> **Note:** This project is a [fork](https://moq.dev/blog/transfork) of the [IETF MoQ specification](https://datatracker.ietf.org/group/moq/documents/). The focus is narrower, focusing on simplicity and deployability.
+> **Note:** This project implements [moq-lite](https://doc.moq.dev/concept/layer/moq-lite), a forwards-compatible subset of the IETF [moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) draft. moq-lite works with any moq-transport CDN (ex. [Cloudflare](https://moq.dev/blog/first-cdn/)). The focus is narrower, prioritizing simplicity and deployability.
+
+## Installing on Linux
+Debian/Ubuntu users can install pre-built packages from `apt.moq.dev`,
+Fedora/RHEL/Rocky/AlmaLinux/openSUSE users from `rpm.moq.dev`. The
+relay ships with a hardened systemd unit and a default config; the
+GStreamer plugin lands in the right multiarch plugin directory and is
+visible to `gst-inspect-1.0` after one install. See
+[Linux Installation](https://doc.moq.dev/setup/linux) for the apt/dnf
+one-liners and the list of available packages.
## Demo
+
This repository is split into multiple binaries and libraries across different languages.
-It can get overwhelming, so there's an included [demo](js/hang-demo) with some examples.
+It can get overwhelming, so there's an included [demo](demo/web) with some examples.
**Note:** this demo uses an insecure HTTP fetch intended for *local development only*.
In production, you'll need a proper domain and a matching TLS certificate via [LetsEncrypt](https://letsencrypt.org/docs/) or similar.
-
### Quick Setup
+
**Requirements:**
+
- [Nix](https://nixos.org/download.html)
- [Nix Flakes enabled](https://nixos.wiki/wiki/Flakes)
```sh
# Runs a relay, demo media, and the web server
-nix develop -c just dev
+nix develop -c just
```
-Then visit [https://localhost:8080](https://localhost:8080) to see the demo.
+Then visit to see the demo.
Note that this uses an insecure HTTP fetch for local development only; in production you'll need a proper domain + TLS certificate.
-*TIP:* If you've installed [nix-direnv](https://github.com/nix-community/nix-direnv), then only `just dev` is required.
-
+*TIP:* If you've installed [nix-direnv](https://github.com/nix-community/nix-direnv), then only `just` is required.
### Full Setup
+
If you don't like Nix, then you can install dependencies manually:
**Requirements:**
+
- [Just](https://github.com/casey/just)
- [Rust](https://www.rust-lang.org/tools/install)
- [Bun](https://bun.sh/)
- [FFmpeg](https://ffmpeg.org/download.html)
-- [Deno](https://deno.com/runtime) (optional)
- ...probably some other stuff
**Run it:**
+
```sh
# Install some more dependencies
just install
# Runs a relay, demo media, and the web server
-just dev
+just
```
-Then visit [https://localhost:8080](https://localhost:8080) to see the demo.
-
+Then visit to see the demo.
## Architecture
@@ -77,7 +89,7 @@ MoQ is designed as a layered protocol stack.
**Rule 1**: The CDN MUST NOT know anything about your application, media codecs, or even the available tracks.
Everything could be fully E2EE and the CDN wouldn't care. **No business logic allowed**.
-Instead, [`moq-relay`](rs/moq-relay) operates on rules encoded in the [`moq-lite`](https://docs.rs/moq-lite) header.
+Instead, [`moq-relay`](rs/moq-relay) operates on rules encoded in the [`moq-net`](https://docs.rs/moq-net) header.
These rules are based on video encoding but are generic enough to be used for any live data.
The goal is to keep the server as dumb as possible while supporting a wide range of use-cases.
@@ -87,7 +99,6 @@ If you want to do something more custom, then you can always extend it or replac
Think of `hang` as like HLS/DASH, while `moq-lite` is like HTTP.
-
```
┌─────────────────┐
│ Application │ 🏢 Your business logic
@@ -104,46 +115,52 @@ Think of `hang` as like HLS/DASH, while `moq-lite` is like HTTP.
└─────────────────┘
```
-
## Libraries
-This repository provides both [Rust](/rs) and [TypeScript](/js) libraries with similar APIs but language-specific optimizations.
+
+This repository provides both [Rust](rs) and [TypeScript](js) libraries with similar APIs but language-specific optimizations.
### Rust
+
| Crate | Description | Docs |
|-----------------------------|---------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
-| [moq-lite](rs/moq-lite) | The core pub/sub transport protocol. Has built-in concurrency and deduplication. | [](https://docs.rs/moq-lite) |
+| [moq-net](rs/moq-net) | The networking layer: real-time pub/sub with built-in caching, fan-out, and prioritization. Negotiates either the `moq-lite` or `moq-transport` wire protocol. | [](https://docs.rs/moq-net) |
| [moq-relay](rs/moq-relay) | A clusterable relay server. This relay performs fan-out connecting multiple clients and servers together. | |
| [moq-token](rs/moq-token) | An authentication scheme supported by `moq-relay`. Can be used as a library or as [a CLI](rs/moq-token-cli) to authenticate sessions. | |
| [moq-native](rs/moq-native) | Opinionated helpers to configure a Quinn QUIC endpoint. It's harder than it should be. | [](https://docs.rs/moq-native) |
-| [libmoq](rs/libmoq) | C bindings for `moq-lite`. | [](https://docs.rs/libmoq) |
-| [hang](rs/hang) | Media-specific encoding/streaming layered on top of `moq-lite`. Can be used as a library or [a CLI](rs/hang-cli). | [](https://docs.rs/hang) |
-| [hang-gst](https://github.com/moq-dev/gstreamer) | A GStreamer plugin for publishing or consuming hang broadcasts. A separate repo to avoid requiring gstreamer as a build dependency. | |
-
+| [libmoq](rs/libmoq) | C bindings for `moq-net`. | [](https://docs.rs/libmoq) |
+| [hang](rs/hang) | Media-specific encoding/streaming layered on top of `moq-net`. Can be used as a library. | [](https://docs.rs/hang) |
+| [moq-cli](rs/moq-cli) | A CLI for publishing media to MoQ relays. | |
+| [moq-mux](rs/moq-mux) | Media muxers and demuxers (fMP4/CMAF, HLS) for importing content into MoQ broadcasts. | [](https://docs.rs/moq-mux) |
+| [moq-gst](rs/moq-gst) | A GStreamer plugin for publishing or consuming MoQ broadcasts. Not built by default; requires GStreamer dev libraries. | |
### TypeScript
| Package | Description | NPM |
|------------------------------------------|--------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------|
-| **[@moq/lite](js/lite)** | The core pub/sub transport protocol. Intended for browsers, but can be run server-side using [Deno](https://deno.com/). | [](https://www.npmjs.com/package/@moq/lite) |
-| **[@moq/token](js/token)** | Authentication library & CLI for JS/TS environments (see [Authentication](doc/concepts/authentication.md)) | [](https://www.npmjs.com/package/@moq/token) |
-| **[@moq/hang](js/hang)** | Media-specific encoding/streaming layered on top of `moq-lite`. Provides both a Javascript API and Web Components. | [](https://www.npmjs.com/package/@moq/hang) |
-| **[@moq/hang-demo](js/hang-demo)** | Examples using `@moq/hang`. | |
-| **[@moq/hang-ui](js/hang-ui)**. | UI Components that interact with the Hang Web Components using SolidJS. | [](https://www.npmjs.com/package/@moq/hang-ui) |
-
+| **[@moq/net](js/net)** | The networking layer: real-time pub/sub with built-in caching, fan-out, and prioritization. Negotiates either the `moq-lite` or `moq-transport` wire protocol. Intended for browsers, runs server-side with a WebTransport polyfill. | [](https://www.npmjs.com/package/@moq/net) |
+| **[@moq/token](js/token)** | Authentication library & CLI for JS/TS environments (see [Authentication](doc/app/relay/auth.md)) | [](https://www.npmjs.com/package/@moq/token) |
+| **[@moq/hang](js/hang)** | Core media library: catalog, container, and support. Shared by `@moq/watch` and `@moq/publish`. | [](https://www.npmjs.com/package/@moq/hang) |
+| **[@moq/demo](demo/web)** | Examples using `@moq/hang`. | |
+| **[@moq/watch](js/watch)** | Subscribe to and render MoQ broadcasts (Web Component + JS API). | [](https://www.npmjs.com/package/@moq/watch) |
+| **[@moq/publish](js/publish)** | Publish media to MoQ broadcasts (Web Component + JS API). | [](https://www.npmjs.com/package/@moq/publish) |
+| **[@moq/ui-core](js/ui-core)** | Shared UI components (Button, Icon, Stats, CSS theme) used by `@moq/watch/ui` and `@moq/publish/ui`. | [](https://www.npmjs.com/package/@moq/ui-core) |
## Documentation
-Additional documentation and implementation details:
-- **[Authentication](doc/concepts/authentication.md)** - JWT tokens, authorization, and security
+Additional documentation and implementation details:
+- **[Authentication](doc/app/relay/auth.md)** - JWT tokens, authorization, and security
## Protocol
+
Read the specifications:
+
- [moq-lite](https://moq-dev.github.io/drafts/draft-lcurley-moq-lite.html)
- [hang](https://moq-dev.github.io/drafts/draft-lcurley-moq-hang.html)
- [use-cases](https://moq-dev.github.io/drafts/draft-lcurley-moq-use-cases.html)
## Development
+
```sh
# See all available commands
just
@@ -163,7 +180,7 @@ just pub tos # Terminal 2: Publish a demo video using ffmpeg
just web # Terminal 3: Start web server
```
-There are more commands: check out the [justfile](justfile), [rs/justfile](rs/justfile), and [js/justfile](js/justfile).
+There are more commands: check out the [justfile](justfile).
## Iroh support
@@ -171,10 +188,10 @@ The `moq-native` and `moq-relay` crates optionally support connecting via [iroh]
When the iroh feature is enabled, you can connect to iroh endpoints with these URLs:
-* `iroh://`: Connect via moq-lite over raw QUIC.
-* `moql+iroh://`: Connect via moq-lite over raw QUIC (same as above)
-* `moqt+iroh://`: Connect via IETF MoQ over raw QUIC
-* `h3+iroh:///optional/path?with=query`: Connect via WebTransport over HTTP/3.
+- `iroh://`: Connect via moq-lite over raw QUIC.
+- `moql+iroh://`: Connect via moq-lite over raw QUIC (same as above)
+- `moqt+iroh://`: Connect via IETF MoQ over raw QUIC
+- `h3+iroh:///optional/path?with=query`: Connect via WebTransport over HTTP/3.
`ENDPOINT_ID` must be the hex-encoded iroh endpoint id. It is currently not possible to set direct addresses or iroh relay URLs. The iroh integration in moq-native uses iroh's default discovery mechanisms to discover other endpoints by their endpoint id.
@@ -192,10 +209,10 @@ just relay --iroh-enabled
# We set an `anon/` prefix to match the broadcast name the web ui expects
# Because moq-lite does not have headers if using raw QUIC, only the hostname
# in the URL can be used.
-just pub bbb iroh://ENDPOINT_ID/anon --iroh-enabled
+just pub-iroh bbb iroh://ENDPOINT_ID anon/
# Alternatively you can use WebTransport over HTTP/3 over iroh,
# which allows to set a path prefix in the URL:
-just pub bbb h3+iroh://ENDPOINT_ID/anon --iroh-enabled
+just pub-iroh bbb h3+iroh://ENDPOINT_ID/anon
# Terminal 3: Start web server
just web
@@ -203,10 +220,11 @@ just web
Then open [localhost:5173](http://localhost:5173) and watch BBB, pushed from terminal 1 via iroh to the relay running in terminal 2, from where the browser fetches it over regular WebTransport.
-`just serve` serves a video via iroh alongside regular QUIC (it enables the `iroh` feature). This repo currently does not provide a native viewer, so you can't subscribe to it directly. However, you can use the [watch example from iroh-live](https://github.com/n0-computer/iroh-live/blob/main/iroh-live/examples/watch.rs) to view a video published via `hang-native`.
+`just serve` serves a video via iroh alongside regular QUIC (it enables the `iroh` feature). This repo currently does not provide a native viewer, so you can't subscribe to it directly. However, you can use the [watch example from iroh-live](https://github.com/n0-computer/iroh-live/blob/main/iroh-live/examples/watch.rs) to view a video published via `moq-native`.
## License
Licensed under either:
-- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
-- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
+
+- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or https://www.apache.org/licenses/LICENSE-2.0)
+- MIT license ([LICENSE-MIT](LICENSE-MIT) or https://opensource.org/licenses/MIT)
diff --git a/biome.jsonc b/biome.jsonc
index 52050a8d28..ebca15ef6d 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -1,6 +1,6 @@
// This has to be in the root otherwise the VSCode plugin will not work out of the box.
{
- "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
+ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
diff --git a/bun.lock b/bun.lock
index 99fb7edc52..465739ff4a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,12 +1,56 @@
{
"lockfileVersion": 1,
- "configVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "moq",
"devDependencies": {
- "@biomejs/biome": "^2.3.11",
+ "@biomejs/biome": "^2.4.10",
"concurrently": "^9.2.1",
+ "publint": "^0.3.18",
+ "remark-cli": "^12.0.1",
+ "remark-frontmatter": "^5.0.0",
+ "remark-lint-list-item-indent": "^4.0.1",
+ "remark-lint-maximum-line-length": "^4.1.1",
+ "remark-lint-no-duplicate-headings": "^4.0.1",
+ "remark-lint-no-undefined-references": "^5.0.2",
+ "remark-preset-lint-consistent": "^6.0.1",
+ "remark-preset-lint-recommended": "^7.0.1",
+ "remark-stringify": "^11.0.0",
+ },
+ },
+ "demo/boy": {
+ "name": "@moq/demo-boy",
+ "version": "0.2.0",
+ "dependencies": {
+ "@moq/boy": "workspace:^",
+ },
+ "devDependencies": {
+ "esbuild": "^0.27.0",
+ "typescript": "^6.0.0",
+ "vite": "^7.3.1",
+ "vite-plugin-solid": "^2.11.10",
+ },
+ },
+ "demo/web": {
+ "name": "@moq/demo",
+ "version": "0.1.1",
+ "dependencies": {
+ "@moq/hang": "workspace:^",
+ "@moq/publish": "workspace:^",
+ "@moq/watch": "workspace:^",
+ },
+ "devDependencies": {
+ "@tailwindcss/typography": "^0.5.16",
+ "@tailwindcss/vite": "^4.1.13",
+ "esbuild": "^0.27.0",
+ "highlight.js": "^11.11.1",
+ "solid-element": "^1.9.1",
+ "solid-js": "^1.9.10",
+ "tailwindcss": "^4.1.13",
+ "typescript": "^6.0.0",
+ "vite": "^7.3.1",
+ "vite-plugin-solid": "^2.11.10",
},
},
"doc": {
@@ -24,105 +68,124 @@
"moq-clock": "./src/main.ts",
},
"dependencies": {
- "@moq/lite": "workspace:*",
+ "@fails-components/webtransport": "^1.5.2",
+ "@fails-components/webtransport-transport-http3-quiche": "^1.5.2",
+ "@moq/net": "workspace:*",
},
"devDependencies": {
- "@types/deno": "^2.3.0",
- "typescript": "^5.9.2",
+ "@types/node": "^24.3.1",
+ "typescript": "^6.0.0",
},
},
"js/hang": {
"name": "@moq/hang",
- "version": "0.1.1",
+ "version": "0.2.7",
"dependencies": {
"@kixelated/libavjs-webcodecs-polyfill": "^0.5.5",
"@libav.js/variant-opus-af": "^6.8.8",
- "@moq/lite": "workspace:^",
+ "@moq/loc": "workspace:^",
+ "@moq/net": "workspace:^",
"@moq/signals": "workspace:^",
- "async-mutex": "^0.5.0",
- "comlink": "^4.4.2",
+ "@svta/cml-iso-bmff": "^1.0.0-alpha.9",
"zod": "^4.1.5",
},
"devDependencies": {
"@types/audioworklet": "^0.0.77",
+ "@types/bun": "^1.3.11",
"@typescript/lib-dom": "npm:@types/web@^0.0.241",
"fast-glob": "^3.3.3",
"rimraf": "^6.0.1",
- "typescript": "^5.9.2",
- "vitest": "^3.2.4",
+ "typescript": "^6.0.0",
},
},
- "js/hang-demo": {
- "name": "@moq/hang-demo",
+ "js/loc": {
+ "name": "@moq/loc",
"version": "0.1.0",
"dependencies": {
- "@moq/hang": "workspace:^",
- "@moq/hang-ui": "workspace:^",
+ "@moq/net": "workspace:^",
},
"devDependencies": {
- "@tailwindcss/typography": "^0.5.16",
- "@tailwindcss/vite": "^4.1.13",
- "highlight.js": "^11.11.1",
- "tailwindcss": "^4.1.13",
- "typescript": "^5.9.2",
- "vite": "^6.3.6",
- "vite-plugin-solid": "^2.11.10",
+ "@types/bun": "^1.3.11",
+ "rimraf": "^6.0.1",
+ "typescript": "^6.0.0",
},
},
- "js/hang-ui": {
- "name": "@moq/hang-ui",
- "version": "0.1.1",
+ "js/moq-boy": {
+ "name": "@moq/boy",
+ "version": "0.2.10",
+ "dependencies": {
+ "@moq/net": "workspace:^",
+ "@moq/signals": "workspace:^",
+ "@moq/watch": "workspace:^",
+ "zod": "^4.3.6",
+ },
"devDependencies": {
- "@solidjs/testing-library": "^0.8.10",
- "@testing-library/jest-dom": "^6.9.1",
- "happy-dom": "^20.0.11",
+ "@types/bun": "^1.3.11",
+ "esbuild": "^0.27.0",
"rimraf": "^6.0.1",
- "solid-element": "^1.9.1",
"solid-js": "^1.9.10",
- "typescript": "^5.9.2",
- "unplugin-solid": "^1.0.0",
+ "typescript": "^6.0.0",
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10",
- "vitest": "^3.2.4",
- },
- "peerDependencies": {
- "@moq/hang": "workspace:^0.1.0",
- "@moq/signals": "workspace:^0.1.0",
},
},
- "js/lite": {
- "name": "@moq/lite",
+ "js/msf": {
+ "name": "@moq/msf",
"version": "0.1.1",
"dependencies": {
+ "@moq/net": "workspace:^",
+ "zod": "^4.1.5",
+ },
+ "devDependencies": {
+ "rimraf": "^6.0.1",
+ "typescript": "^6.0.0",
+ },
+ },
+ "js/net": {
+ "name": "@moq/net",
+ "version": "0.1.2",
+ "dependencies": {
+ "@moq/qmux": "^0.0.6",
"@moq/signals": "workspace:*",
- "@moq/web-transport-ws": "^0.1.2",
"async-mutex": "^0.5.0",
},
"devDependencies": {
- "@types/deno": "^2.3.0",
+ "@types/bun": "^1.3.11",
"@types/node": "^24.3.1",
"@typescript/lib-dom": "npm:@types/web@^0.0.241",
- "madge": "^8.0.0",
"rimraf": "^6.0.1",
- "typescript": "^5.9.2",
- "vite": "^6.3.6",
+ "typescript": "^6.0.0",
"vite-plugin-html": "^3.2.2",
},
"peerDependencies": {
- "zod": "^4.1.0",
+ "zod": "^4.0.0",
},
},
- "js/signals": {
- "name": "@moq/signals",
- "version": "0.1.0",
+ "js/publish": {
+ "name": "@moq/publish",
+ "version": "0.2.9",
"dependencies": {
- "dequal": "^2.0.3",
+ "@moq/hang": "workspace:^",
+ "@moq/net": "workspace:^",
+ "@moq/signals": "workspace:^",
},
+ "devDependencies": {
+ "@types/audioworklet": "^0.0.77",
+ "@typescript/lib-dom": "npm:@types/web@^0.0.241",
+ "esbuild": "^0.27.0",
+ "rimraf": "^6.0.1",
+ "typescript": "^6.0.0",
+ "vite": "^7.3.1",
+ },
+ },
+ "js/signals": {
+ "name": "@moq/signals",
+ "version": "0.1.7",
"devDependencies": {
"@types/react": "^19.1.8",
"rimraf": "^6.0.1",
"solid-js": "^1.9.7",
- "typescript": "^5.9.2",
+ "typescript": "^6.0.0",
},
"peerDependencies": {
"@types/react": "^19.1.8",
@@ -130,13 +193,14 @@
"solid-js": "^1.9.7",
},
"optionalPeers": [
+ "@types/react",
"react",
"solid-js",
],
},
"js/token": {
"name": "@moq/token",
- "version": "0.1.1",
+ "version": "0.2.3",
"bin": {
"moq-token": "./src/cli.ts",
},
@@ -147,15 +211,37 @@
"zod": "^4.1.5",
},
"devDependencies": {
+ "@types/bun": "^1.3.11",
"@types/node": "^24.3.1",
- "typescript": "^5.9.2",
+ "rimraf": "^6.0.1",
+ "typescript": "^6.0.0",
+ },
+ },
+ "js/watch": {
+ "name": "@moq/watch",
+ "version": "0.2.13",
+ "dependencies": {
+ "@moq/hang": "workspace:^",
+ "@moq/msf": "workspace:^",
+ "@moq/net": "workspace:^",
+ "@moq/signals": "workspace:^",
+ },
+ "devDependencies": {
+ "@types/audioworklet": "^0.0.77",
+ "@types/bun": "^1.3.11",
+ "@typescript/lib-dom": "npm:@types/web@^0.0.241",
+ "esbuild": "^0.27.0",
+ "rimraf": "^6.0.1",
+ "typescript": "^6.0.0",
+ "vite": "^7.3.1",
},
},
},
+ "trustedDependencies": [
+ "@fails-components/webtransport-transport-http3-quiche",
+ ],
"packages": {
- "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
-
- "@algolia/abtesting": ["@algolia/abtesting@1.12.2", "", { "dependencies": { "@algolia/client-common": "5.46.2", "@algolia/requester-browser-xhr": "5.46.2", "@algolia/requester-fetch": "5.46.2", "@algolia/requester-node-http": "5.46.2" } }, "sha512-oWknd6wpfNrmRcH0vzed3UPX0i17o4kYLM5OMITyMVM2xLgaRbIafoxL0e8mcrNNb0iORCJA0evnNDKRYth5WQ=="],
+ "@algolia/abtesting": ["@algolia/abtesting@1.14.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-Dkj0BgPiLAaim9sbQ97UKDFHJE/880wgStAM18U++NaJ/2Cws34J5731ovJifr6E3Pv4T2CqvMXf8qLCC417Ew=="],
"@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="],
@@ -165,49 +251,49 @@
"@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.17.7", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg=="],
- "@algolia/client-abtesting": ["@algolia/client-abtesting@5.46.2", "", { "dependencies": { "@algolia/client-common": "5.46.2", "@algolia/requester-browser-xhr": "5.46.2", "@algolia/requester-fetch": "5.46.2", "@algolia/requester-node-http": "5.46.2" } }, "sha512-oRSUHbylGIuxrlzdPA8FPJuwrLLRavOhAmFGgdAvMcX47XsyM+IOGa9tc7/K5SPvBqn4nhppOCEz7BrzOPWc4A=="],
+ "@algolia/client-abtesting": ["@algolia/client-abtesting@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-LV5qCJdj+/m9I+Aj91o+glYszrzd7CX6NgKaYdTOj4+tUYfbS62pwYgUfZprYNayhkQpVFcrW8x8ZlIHpS23Vw=="],
- "@algolia/client-analytics": ["@algolia/client-analytics@5.46.2", "", { "dependencies": { "@algolia/client-common": "5.46.2", "@algolia/requester-browser-xhr": "5.46.2", "@algolia/requester-fetch": "5.46.2", "@algolia/requester-node-http": "5.46.2" } }, "sha512-EPBN2Oruw0maWOF4OgGPfioTvd+gmiNwx0HmD9IgmlS+l75DatcBkKOPNJN+0z3wBQWUO5oq602ATxIfmTQ8bA=="],
+ "@algolia/client-analytics": ["@algolia/client-analytics@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-/AVoMqHhPm14CcHq7mwB+bUJbfCv+jrxlNvRjXAuO+TQa+V37N8k1b0ijaRBPdmSjULMd8KtJbQyUyabXOu6Kg=="],
- "@algolia/client-common": ["@algolia/client-common@5.46.2", "", {}, "sha512-Hj8gswSJNKZ0oyd0wWissqyasm+wTz1oIsv5ZmLarzOZAp3vFEda8bpDQ8PUhO+DfkbiLyVnAxsPe4cGzWtqkg=="],
+ "@algolia/client-common": ["@algolia/client-common@5.48.1", "", {}, "sha512-VXO+qu2Ep6ota28ktvBm3sG53wUHS2n7bgLWmce5jTskdlCD0/JrV4tnBm1l7qpla1CeoQb8D7ShFhad+UoSOw=="],
- "@algolia/client-insights": ["@algolia/client-insights@5.46.2", "", { "dependencies": { "@algolia/client-common": "5.46.2", "@algolia/requester-browser-xhr": "5.46.2", "@algolia/requester-fetch": "5.46.2", "@algolia/requester-node-http": "5.46.2" } }, "sha512-6dBZko2jt8FmQcHCbmNLB0kCV079Mx/DJcySTL3wirgDBUH7xhY1pOuUTLMiGkqM5D8moVZTvTdRKZUJRkrwBA=="],
+ "@algolia/client-insights": ["@algolia/client-insights@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-zl+Qyb0nLg+Y5YvKp1Ij+u9OaPaKg2/EPzTwKNiVyOHnQJlFxmXyUZL1EInczAZsEY8hVpPCLtNfhMhfxluXKQ=="],
- "@algolia/client-personalization": ["@algolia/client-personalization@5.46.2", "", { "dependencies": { "@algolia/client-common": "5.46.2", "@algolia/requester-browser-xhr": "5.46.2", "@algolia/requester-fetch": "5.46.2", "@algolia/requester-node-http": "5.46.2" } }, "sha512-1waE2Uqh/PHNeDXGn/PM/WrmYOBiUGSVxAWqiJIj73jqPqvfzZgzdakHscIVaDl6Cp+j5dwjsZ5LCgaUr6DtmA=="],
+ "@algolia/client-personalization": ["@algolia/client-personalization@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-r89Qf9Oo9mKWQXumRu/1LtvVJAmEDpn8mHZMc485pRfQUMAwSSrsnaw1tQ3sszqzEgAr1c7rw6fjBI+zrAXTOw=="],
- "@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.46.2", "", { "dependencies": { "@algolia/client-common": "5.46.2", "@algolia/requester-browser-xhr": "5.46.2", "@algolia/requester-fetch": "5.46.2", "@algolia/requester-node-http": "5.46.2" } }, "sha512-EgOzTZkyDcNL6DV0V/24+oBJ+hKo0wNgyrOX/mePBM9bc9huHxIY2352sXmoZ648JXXY2x//V1kropF/Spx83w=="],
+ "@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-TPKNPKfghKG/bMSc7mQYD9HxHRUkBZA4q1PEmHgICaSeHQscGqL4wBrKkhfPlDV1uYBKW02pbFMUhsOt7p4ZpA=="],
- "@algolia/client-search": ["@algolia/client-search@5.46.2", "", { "dependencies": { "@algolia/client-common": "5.46.2", "@algolia/requester-browser-xhr": "5.46.2", "@algolia/requester-fetch": "5.46.2", "@algolia/requester-node-http": "5.46.2" } }, "sha512-ZsOJqu4HOG5BlvIFnMU0YKjQ9ZI6r3C31dg2jk5kMWPSdhJpYL9xa5hEe7aieE+707dXeMI4ej3diy6mXdZpgA=="],
+ "@algolia/client-search": ["@algolia/client-search@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q=="],
- "@algolia/ingestion": ["@algolia/ingestion@1.46.2", "", { "dependencies": { "@algolia/client-common": "5.46.2", "@algolia/requester-browser-xhr": "5.46.2", "@algolia/requester-fetch": "5.46.2", "@algolia/requester-node-http": "5.46.2" } }, "sha512-1Uw2OslTWiOFDtt83y0bGiErJYy5MizadV0nHnOoHFWMoDqWW0kQoMFI65pXqRSkVvit5zjXSLik2xMiyQJDWQ=="],
+ "@algolia/ingestion": ["@algolia/ingestion@1.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-/RFq3TqtXDUUawwic/A9xylA2P3LDMO8dNhphHAUOU51b1ZLHrmZ6YYJm3df1APz7xLY1aht6okCQf+/vmrV9w=="],
- "@algolia/monitoring": ["@algolia/monitoring@1.46.2", "", { "dependencies": { "@algolia/client-common": "5.46.2", "@algolia/requester-browser-xhr": "5.46.2", "@algolia/requester-fetch": "5.46.2", "@algolia/requester-node-http": "5.46.2" } }, "sha512-xk9f+DPtNcddWN6E7n1hyNNsATBCHIqAvVGG2EAGHJc4AFYL18uM/kMTiOKXE/LKDPyy1JhIerrh9oYb7RBrgw=="],
+ "@algolia/monitoring": ["@algolia/monitoring@1.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-Of0jTeAZRyRhC7XzDSjJef0aBkgRcvRAaw0ooYRlOw57APii7lZdq+layuNdeL72BRq1snaJhoMMwkmLIpJScw=="],
- "@algolia/recommend": ["@algolia/recommend@5.46.2", "", { "dependencies": { "@algolia/client-common": "5.46.2", "@algolia/requester-browser-xhr": "5.46.2", "@algolia/requester-fetch": "5.46.2", "@algolia/requester-node-http": "5.46.2" } }, "sha512-NApbTPj9LxGzNw4dYnZmj2BoXiAc8NmbbH6qBNzQgXklGklt/xldTvu+FACN6ltFsTzoNU6j2mWNlHQTKGC5+Q=="],
+ "@algolia/recommend": ["@algolia/recommend@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-bE7JcpFXzxF5zHwj/vkl2eiCBvyR1zQ7aoUdO+GDXxGp0DGw7nI0p8Xj6u8VmRQ+RDuPcICFQcCwRIJT5tDJFw=="],
- "@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.46.2", "", { "dependencies": { "@algolia/client-common": "5.46.2" } }, "sha512-ekotpCwpSp033DIIrsTpYlGUCF6momkgupRV/FA3m62SreTSZUKjgK6VTNyG7TtYfq9YFm/pnh65bATP/ZWJEg=="],
+ "@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1" } }, "sha512-MK3wZ2koLDnvH/AmqIF1EKbJlhRS5j74OZGkLpxI4rYvNi9Jn/C7vb5DytBnQ4KUWts7QsmbdwHkxY5txQHXVw=="],
- "@algolia/requester-fetch": ["@algolia/requester-fetch@5.46.2", "", { "dependencies": { "@algolia/client-common": "5.46.2" } }, "sha512-gKE+ZFi/6y7saTr34wS0SqYFDcjHW4Wminv8PDZEi0/mE99+hSrbKgJWxo2ztb5eqGirQTgIh1AMVacGGWM1iw=="],
+ "@algolia/requester-fetch": ["@algolia/requester-fetch@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1" } }, "sha512-2oDT43Y5HWRSIQMPQI4tA/W+TN/N2tjggZCUsqQV440kxzzoPGsvv9QP1GhQ4CoDa+yn6ygUsGp6Dr+a9sPPSg=="],
- "@algolia/requester-node-http": ["@algolia/requester-node-http@5.46.2", "", { "dependencies": { "@algolia/client-common": "5.46.2" } }, "sha512-ciPihkletp7ttweJ8Zt+GukSVLp2ANJHU+9ttiSxsJZThXc4Y2yJ8HGVWesW5jN1zrsZsezN71KrMx/iZsOYpg=="],
+ "@algolia/requester-node-http": ["@algolia/requester-node-http@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1" } }, "sha512-xcaCqbhupVWhuBP1nwbk1XNvwrGljozutEiLx06mvqDf3o8cHyEgQSHS4fKJM+UAggaWVnnFW+Nne5aQ8SUJXg=="],
- "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
+ "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
- "@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
+ "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
- "@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
+ "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
- "@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
+ "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
- "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
+ "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
- "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
+ "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
- "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
+ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
- "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="],
+ "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
@@ -215,56 +301,54 @@
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
- "@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
+ "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
- "@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
+ "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
- "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w=="],
+ "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
- "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
+ "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
- "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
+ "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
- "@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
+ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
- "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
+ "@biomejs/biome": ["@biomejs/biome@2.4.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="],
- "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="],
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="],
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="],
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA=="],
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="],
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw=="],
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="],
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ=="],
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="],
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg=="],
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="],
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw=="],
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="],
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ=="],
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="],
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="],
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="],
+ "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
- "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.1", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg=="],
+ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.12.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260115.0" }, "optionalPeers": ["workerd"] }, "sha512-tP/Wi+40aBJovonSNJSsS7aFJY0xjuckKplmzDs2Xat06BJ68B6iG7YDUWXJL8gNn0gqW7YC5WhlYhO3QbugQA=="],
- "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.8.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20251202.0" }, "optionalPeers": ["workerd"] }, "sha512-oIAu6EdQ4zJuPwwKr9odIEqd8AV96z1aqi3RBEA4iKaJ+Vd3fvuI6m5EDC7/QCv+oaPIhy1SkYBYxmD09N+oZg=="],
+ "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260212.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-kLxuYutk88Wlo7edp8mlkN68TgZZ9237SUnuX9kNaD5jcOdblUqiBctMRZeRcPsuoX/3g2t0vS4ga02NBEVRNg=="],
- "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260107.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Srwe/IukVppkMU2qTndkFaKCmZBI7CnZoq4Y0U0gD/8158VGzMREHTqCii4IcCeHifwrtDqTWu8EcA1VBKI4mg=="],
+ "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260212.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fqoqQWMA1D0ZzDOD8sp0allREM2M8GHdpxMXQ8EdZpZ70z5bJbJ9Vr4qe35++FNIZJspsDHfTw3Xm/M4ELm/dQ=="],
- "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260107.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aAYwU7zXW+UZFh/a4vHP5cs1ulTOcDRLzwU9547yKad06RlZ6ioRm7ovjdYvdqdmbI8mPd99v4LN9gMmecazQw=="],
+ "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260212.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bCSQoZzDzV5MSh4ueWo1DgmOn4Hf3QBu4Yo3eQFXA2llYFIu/sZgRtkEehw1X2/SY5Sn6O0EMCqxJYRf82Wdeg=="],
- "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260107.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Wh7xWtFOkk6WY3CXe3lSqZ1anMkFcwy+qOGIjtmvQ/3nCOaG34vKNwPIE9iwryPupqkSuDmEqkosI1UUnSTh1A=="],
+ "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260212.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GPvp1iiKQodtbUDi6OmR5I0vD75lawB54tdYGtmypuHC7ZOI2WhBmhb3wCxgnQNOG1z7mhCQrzRCoqrKwYbVWQ=="],
- "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260107.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-NI0/5rdssdZZKYHxNG4umTmMzODByq86vSCEk8u4HQbGhRCQo7rV1eXn84ntSBdyWBzWdYGISCbeZMsgfIjSTg=="],
+ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260212.0", "", { "os": "win32", "cpu": "x64" }, "sha512-wHRI218Xn4ndgWJCUHH4Zx0YlU5q/o6OmcxXkcw95tJOsQn4lDrhppioPh4eScxJZALf2X+ODeZcyQTCq5exGw=="],
- "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260107.1", "", { "os": "win32", "cpu": "x64" }, "sha512-gmBMqs606Gd/IhBEBPSL/hJAqy2L8IyPUjKtoqd/Ccy7GQxbSc0rYlRkxbQ9YzmqnuhrTVYvXuLscyWrpmAJkw=="],
+ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260403.1", "", {}, "sha512-p6oSNt3yUwcxSoXZ7qCog7+kgQbkSx1Beoci7TMb/xbRF05VR0cO/p1XUE2SLHZ7IgSIc3tNMpFKa0L0fa3Lzg=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
- "@dependents/detective-less": ["@dependents/detective-less@5.0.1", "", { "dependencies": { "gonzales-pe": "^4.3.0", "node-source-walk": "^7.0.1" } }, "sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ=="],
-
"@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="],
"@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="],
@@ -273,105 +357,121 @@
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
- "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
- "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
- "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
- "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
- "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
- "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
- "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
- "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
- "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
- "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
- "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
- "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
- "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
- "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
- "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
- "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
- "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
- "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
- "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
- "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
- "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
- "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
- "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
- "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
- "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
- "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
+
+ "@fails-components/webtransport": ["@fails-components/webtransport@1.5.3", "", { "dependencies": { "@types/debug": "^4.1.7", "bindings": "^1.5.0", "debug": "^4.3.4" } }, "sha512-ax+xQPaYm29hSPxEGaFzQkYkpLVsvDjXn3eabn9E0TlESW2y/HRgTIABG73HjypCaHnhDYP8Q6QkN/uWUhQH2A=="],
+
+ "@fails-components/webtransport-transport-http3-quiche": ["@fails-components/webtransport-transport-http3-quiche@1.5.3", "", { "dependencies": { "@types/debug": "^4.1.7", "bindings": "^1.5.0", "cmake-js": "^8.0.0", "debug": "^4.3.4", "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.1" } }, "sha512-PlR/jYHgEwbv6yQywH5D9czRs+2k05Mcc9/6Uh2b0ShCuOxQcbfPgJ9St9r75AMbKcDVu2Ibm0Lmt3JFCB5HEg=="],
"@hexagon/base64": ["@hexagon/base64@2.0.4", "", {}, "sha512-H/ZY6rGyaEuk0mwQgZ3BVi9hMjFTYpBNFbmtOuec/pPibuGhCMXd8fGtwBaO0h44FkWMurysMsDrpkJsBRmoWQ=="],
- "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.65", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-v/O0UeqrDz6ASuRVE5g2Puo5aWyej4M/CxX6WYDBARgswwxK0mp3VQbGgPFEAAUU9QN02IjTgjMuO021gpWf2w=="],
+ "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.70", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-CYNRCgN6nBTjN4dNkrBCjHXNR2e4hQihdsZUs/afUNFOWLSYjfihca4EFN05rRvDk4Xoy2n8tym6IxBZmcn+Qg=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
- "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
+ "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
+
+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
+
+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
+
+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
+
+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
+
+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
- "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
- "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
+ "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
- "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
+ "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
- "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
+ "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
- "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
- "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
+ "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
- "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
+ "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
- "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
- "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
- "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
+ "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
- "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
+ "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
- "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
+ "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
- "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
- "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
+ "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
- "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
+ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
- "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
+ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
- "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
+ "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
- "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
+ "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
- "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
- "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
+ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
+
+ "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -391,21 +491,31 @@
"@libav.js/variant-opus-af": ["@libav.js/variant-opus-af@6.8.8", "", {}, "sha512-8KBQyA8n5goN7lyctOaPxpcx7dapOgqKh8dWW/NAcl87AgM/WoUGSex3fFc46oCtTHYrUKEm1OmZUrtkt3Q56A=="],
+ "@moq/boy": ["@moq/boy@workspace:js/moq-boy"],
+
"@moq/clock": ["@moq/clock@workspace:js/clock"],
+ "@moq/demo": ["@moq/demo@workspace:demo/web"],
+
+ "@moq/demo-boy": ["@moq/demo-boy@workspace:demo/boy"],
+
"@moq/hang": ["@moq/hang@workspace:js/hang"],
- "@moq/hang-demo": ["@moq/hang-demo@workspace:js/hang-demo"],
+ "@moq/loc": ["@moq/loc@workspace:js/loc"],
+
+ "@moq/msf": ["@moq/msf@workspace:js/msf"],
+
+ "@moq/net": ["@moq/net@workspace:js/net"],
- "@moq/hang-ui": ["@moq/hang-ui@workspace:js/hang-ui"],
+ "@moq/publish": ["@moq/publish@workspace:js/publish"],
- "@moq/lite": ["@moq/lite@workspace:js/lite"],
+ "@moq/qmux": ["@moq/qmux@0.0.6", "", {}, "sha512-ISuGz05lUvf1hzHW3Aw3VnsGRJe1w9Qdog3LQ66KS+l+5mzQsPANvW8yOioEe1Z9dJO2G3sAHoGPnzwnsY9SIQ=="],
"@moq/signals": ["@moq/signals@workspace:js/signals"],
"@moq/token": ["@moq/token@workspace:js/token"],
- "@moq/web-transport-ws": ["@moq/web-transport-ws@0.1.2", "", {}, "sha512-mYha+AkLNPT3uOGnTA5YWjpxc9LO/yriFSoWzKkR0zN3UMZb9RXbsD8Gbhg1pJZod6QD4tevHoOWTBADYN7yAQ=="],
+ "@moq/watch": ["@moq/watch@workspace:js/watch"],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -413,63 +523,79 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
+ "@npmcli/config": ["@npmcli/config@8.3.4", "", { "dependencies": { "@npmcli/map-workspaces": "^3.0.2", "@npmcli/package-json": "^5.1.1", "ci-info": "^4.0.0", "ini": "^4.1.2", "nopt": "^7.2.1", "proc-log": "^4.2.0", "semver": "^7.3.5", "walk-up-path": "^3.0.1" } }, "sha512-01rtHedemDNhUXdicU7s+QYz/3JyV5Naj84cvdXGH4mgCdL+agmSYaLF4LUG4vMCLzhBO8YtS0gPpH1FGvbgAw=="],
+
+ "@npmcli/git": ["@npmcli/git@5.0.8", "", { "dependencies": { "@npmcli/promise-spawn": "^7.0.0", "ini": "^4.1.3", "lru-cache": "^10.0.1", "npm-pick-manifest": "^9.0.0", "proc-log": "^4.0.0", "promise-inflight": "^1.0.1", "promise-retry": "^2.0.1", "semver": "^7.3.5", "which": "^4.0.0" } }, "sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ=="],
+
+ "@npmcli/map-workspaces": ["@npmcli/map-workspaces@3.0.6", "", { "dependencies": { "@npmcli/name-from-folder": "^2.0.0", "glob": "^10.2.2", "minimatch": "^9.0.0", "read-package-json-fast": "^3.0.0" } }, "sha512-tkYs0OYnzQm6iIRdfy+LcLBjcKuQCeE5YLb8KnrIlutJfheNaPvPpgoFEyEFgbjzl5PLZ3IA/BWAwRU0eHuQDA=="],
+
+ "@npmcli/name-from-folder": ["@npmcli/name-from-folder@2.0.0", "", {}, "sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg=="],
+
+ "@npmcli/package-json": ["@npmcli/package-json@5.2.1", "", { "dependencies": { "@npmcli/git": "^5.0.0", "glob": "^10.2.2", "hosted-git-info": "^7.0.0", "json-parse-even-better-errors": "^3.0.0", "normalize-package-data": "^6.0.0", "proc-log": "^4.0.0", "semver": "^7.5.3" } }, "sha512-f7zYC6kQautXHvNbLEWgD/uGu1+xCn9izgqBfgItWSx22U0ZDekxN08A1vM8cTxj/cRVe0Q94Ode+tdoYmIOOQ=="],
+
+ "@npmcli/promise-spawn": ["@npmcli/promise-spawn@7.0.2", "", { "dependencies": { "which": "^4.0.0" } }, "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ=="],
+
+ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
+
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
- "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
+ "@publint/pack": ["@publint/pack@0.1.4", "", {}, "sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ=="],
+
+ "@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
- "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="],
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
- "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="],
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
- "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.55.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg=="],
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
- "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.55.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ=="],
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
- "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.55.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg=="],
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
- "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.55.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw=="],
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
- "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ=="],
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
- "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg=="],
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
- "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ=="],
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
- "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA=="],
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
- "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g=="],
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
- "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw=="],
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
- "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw=="],
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
- "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw=="],
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
- "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw=="],
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
- "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg=="],
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
- "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg=="],
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
- "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg=="],
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
- "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w=="],
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
- "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.55.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg=="],
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
- "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="],
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
- "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="],
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
- "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="],
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
- "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg=="],
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
- "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="],
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
"@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="],
@@ -489,10 +615,12 @@
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
- "@solidjs/testing-library": ["@solidjs/testing-library@0.8.10", "", { "dependencies": { "@testing-library/dom": "^10.4.0" }, "peerDependencies": { "@solidjs/router": ">=0.9.0", "solid-js": ">=1.0.0" }, "optionalPeers": ["@solidjs/router"] }, "sha512-qdeuIerwyq7oQTIrrKvV0aL9aFeuwTd86VYD3afdq5HYEwoox1OBTJy4y8A3TFZr8oAR0nujYgCzY/8wgHGfeQ=="],
-
"@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="],
+ "@svta/cml-iso-bmff": ["@svta/cml-iso-bmff@1.0.1", "", { "peerDependencies": { "@svta/cml-utils": "1.4.0" } }, "sha512-MOhATJYQ6cVrIcoY3nj8p/vGYDpG3wjQIIhBPHNt9yjFijdwFdBNqdZbCXv3aFhRjdx5Saca5TkgNJusKhnI/w=="],
+
+ "@svta/cml-utils": ["@svta/cml-utils@1.4.0", "", {}, "sha512-vNtHtv/z+9I9ysxFwNrgwxic1oceVPr8TpcpV/NA1l8Gy4phynwtOppkCIBB+PmoyKDcqE4lO85g+lfsuSTBBA=="],
+
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
@@ -525,20 +653,6 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
- "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
-
- "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
-
- "@ts-graphviz/adapter": ["@ts-graphviz/adapter@2.0.6", "", { "dependencies": { "@ts-graphviz/common": "^2.1.5" } }, "sha512-kJ10lIMSWMJkLkkCG5gt927SnGZcBuG0s0HHswGzcHTgvtUe7yk5/3zTEr0bafzsodsOq5Gi6FhQeV775nC35Q=="],
-
- "@ts-graphviz/ast": ["@ts-graphviz/ast@2.0.7", "", { "dependencies": { "@ts-graphviz/common": "^2.1.5" } }, "sha512-e6+2qtNV99UT6DJSoLbHfkzfyqY84aIuoV8Xlb9+hZAjgpum8iVHprGeAMQ4rF6sKUAxrmY8rfF/vgAwoPc3gw=="],
-
- "@ts-graphviz/common": ["@ts-graphviz/common@2.1.5", "", {}, "sha512-S6/9+T6x8j6cr/gNhp+U2olwo1n0jKj/682QVqsh7yXWV6ednHYqxFw0ZsY3LyzT0N8jaZ6jQY9YD99le3cmvg=="],
-
- "@ts-graphviz/core": ["@ts-graphviz/core@2.0.7", "", { "dependencies": { "@ts-graphviz/ast": "^2.0.7", "@ts-graphviz/common": "^2.1.5" } }, "sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg=="],
-
- "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
-
"@types/audioworklet": ["@types/audioworklet@0.0.77", "", {}, "sha512-aPQ0DurtnDRWO3qvu8iK1R0aDlKS7sDOWx7MVhMWkGTmlyfnf50GPKYJlKdq8UP159ntPiqPJ5XZ/2v1R0V0Bw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@@ -549,16 +663,20 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
- "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+ "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
- "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
+ "@types/concat-stream": ["@types/concat-stream@2.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-3qe4oQAPNwVNwK4C9c8u+VJqv9kez+2MR4qJpoPFfXtgxxif1QbFusvXzK0/Wra2VX07smostI2VMmJNSpZjuQ=="],
- "@types/deno": ["@types/deno@2.5.0", "", {}, "sha512-g8JS38vmc0S87jKsFzre+0ZyMOUDHPVokEJymSCRlL57h6f/FdKPWBXgdFh3Z8Ees9sz11qt9VWELU9Y9ZkiVw=="],
+ "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
+
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
+ "@types/is-empty": ["@types/is-empty@1.2.3", "", {}, "sha512-4J1l5d79hoIvsrKh5VUKVRA1aIdsOb10Hu5j3J2VfP/msDnfTdGPmNp2E1Wg+vs97Bktzo+MZePFFXSGoykYJw=="],
+
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
@@ -567,27 +685,19 @@
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
- "@types/node": ["@types/node@24.10.7", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ=="],
-
- "@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="],
-
- "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
-
- "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
+ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
- "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
+ "@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="],
- "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
- "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.52.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.52.0", "@typescript-eslint/types": "^8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw=="],
+ "@types/supports-color": ["@types/supports-color@8.1.3", "", {}, "sha512-Hy6UMpxhE3j1tLpl27exp1XqHD7n8chAiNPzWfz16LPZoMMoSc4dzLl6w9qijkEb/r5O1ozdu1CWGA2L83ZeZg=="],
- "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.52.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg=="],
+ "@types/text-table": ["@types/text-table@0.2.5", "", {}, "sha512-hcZhlNvMkQG/k1vcZ6yHOl6WAYftQ2MLfTHcYRZ2xYZFD8tGVnE3qFV0lj1smQeDSR7/yY0PyuUalauf33bJeA=="],
- "@typescript-eslint/types": ["@typescript-eslint/types@8.52.0", "", {}, "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg=="],
-
- "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.52.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.52.0", "@typescript-eslint/tsconfig-utils": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ=="],
+ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
- "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ=="],
+ "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@typescript/lib-dom": ["@types/web@0.0.241", "", {}, "sha512-aHz73musjS4z8Sm0wLaEj4lOaRxhKZRD4XerKL4rxNhhqmvthKkYzS0kHLEVsjOFRN2zo3gQAlyf0EKL1QYYQg=="],
@@ -597,27 +707,13 @@
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
- "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
-
- "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
-
- "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
-
- "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
+ "@vue/compiler-core": ["@vue/compiler-core@3.5.28", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.28", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ=="],
- "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
+ "@vue/compiler-dom": ["@vue/compiler-dom@3.5.28", "", { "dependencies": { "@vue/compiler-core": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA=="],
- "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
+ "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.28", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.28", "@vue/compiler-dom": "3.5.28", "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g=="],
- "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
-
- "@vue/compiler-core": ["@vue/compiler-core@3.5.26", "", { "dependencies": { "@babel/parser": "^7.28.5", "@vue/shared": "3.5.26", "entities": "^7.0.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w=="],
-
- "@vue/compiler-dom": ["@vue/compiler-dom@3.5.26", "", { "dependencies": { "@vue/compiler-core": "3.5.26", "@vue/shared": "3.5.26" } }, "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A=="],
-
- "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.26", "", { "dependencies": { "@babel/parser": "^7.28.5", "@vue/compiler-core": "3.5.26", "@vue/compiler-dom": "3.5.26", "@vue/compiler-ssr": "3.5.26", "@vue/shared": "3.5.26", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA=="],
-
- "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.26", "", { "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/shared": "3.5.26" } }, "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw=="],
+ "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.28", "", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g=="],
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
@@ -625,15 +721,15 @@
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
- "@vue/reactivity": ["@vue/reactivity@3.5.26", "", { "dependencies": { "@vue/shared": "3.5.26" } }, "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ=="],
+ "@vue/reactivity": ["@vue/reactivity@3.5.28", "", { "dependencies": { "@vue/shared": "3.5.28" } }, "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw=="],
- "@vue/runtime-core": ["@vue/runtime-core@3.5.26", "", { "dependencies": { "@vue/reactivity": "3.5.26", "@vue/shared": "3.5.26" } }, "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q=="],
+ "@vue/runtime-core": ["@vue/runtime-core@3.5.28", "", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ=="],
- "@vue/runtime-dom": ["@vue/runtime-dom@3.5.26", "", { "dependencies": { "@vue/reactivity": "3.5.26", "@vue/runtime-core": "3.5.26", "@vue/shared": "3.5.26", "csstype": "^3.2.3" } }, "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ=="],
+ "@vue/runtime-dom": ["@vue/runtime-dom@3.5.28", "", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/runtime-core": "3.5.28", "@vue/shared": "3.5.28", "csstype": "^3.2.3" } }, "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA=="],
- "@vue/server-renderer": ["@vue/server-renderer@3.5.26", "", { "dependencies": { "@vue/compiler-ssr": "3.5.26", "@vue/shared": "3.5.26" }, "peerDependencies": { "vue": "3.5.26" } }, "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA=="],
+ "@vue/server-renderer": ["@vue/server-renderer@3.5.28", "", { "dependencies": { "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "vue": "3.5.28" } }, "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg=="],
- "@vue/shared": ["@vue/shared@3.5.26", "", {}, "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A=="],
+ "@vue/shared": ["@vue/shared@3.5.28", "", {}, "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ=="],
"@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="],
@@ -643,25 +739,17 @@
"@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="],
- "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
+ "abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
- "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
+ "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
- "algoliasearch": ["algoliasearch@5.46.2", "", { "dependencies": { "@algolia/abtesting": "1.12.2", "@algolia/client-abtesting": "5.46.2", "@algolia/client-analytics": "5.46.2", "@algolia/client-common": "5.46.2", "@algolia/client-insights": "5.46.2", "@algolia/client-personalization": "5.46.2", "@algolia/client-query-suggestions": "5.46.2", "@algolia/client-search": "5.46.2", "@algolia/ingestion": "1.46.2", "@algolia/monitoring": "1.46.2", "@algolia/recommend": "5.46.2", "@algolia/requester-browser-xhr": "5.46.2", "@algolia/requester-fetch": "5.46.2", "@algolia/requester-node-http": "5.46.2" } }, "sha512-qqAXW9QvKf2tTyhpDA4qXv1IfBwD2eduSW6tUEBFIfCeE9gn9HQ9I5+MaKoenRuHrzk5sQoNh1/iof8mY7uD6Q=="],
+ "algoliasearch": ["algoliasearch@5.48.1", "", { "dependencies": { "@algolia/abtesting": "1.14.1", "@algolia/client-abtesting": "5.48.1", "@algolia/client-analytics": "5.48.1", "@algolia/client-common": "5.48.1", "@algolia/client-insights": "5.48.1", "@algolia/client-personalization": "5.48.1", "@algolia/client-query-suggestions": "5.48.1", "@algolia/client-search": "5.48.1", "@algolia/ingestion": "1.48.1", "@algolia/monitoring": "1.48.1", "@algolia/recommend": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg=="],
- "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
- "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
-
- "app-module-path": ["app-module-path@2.2.0", "", {}, "sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ=="],
-
- "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
-
- "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
-
- "ast-module-types": ["ast-module-types@6.0.1", "", {}, "sha512-WHw67kLXYbZuHTmcdbIrVArCq5wxo6NEuj3hiYAWr8mwJeC+C2mMCIBIWCiDoCye/OF/xelc+teJ1ERoWmnEIA=="],
+ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
@@ -671,11 +759,17 @@
"babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
- "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
+
+ "balanced-match": ["balanced-match@4.0.2", "", { "dependencies": { "jackspeak": "^4.2.3" } }, "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
- "baseline-browser-mapping": ["baseline-browser-mapping@2.9.14", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg=="],
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
+
+ "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
+
+ "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
@@ -685,7 +779,7 @@
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
- "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+ "brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@@ -695,55 +789,51 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
- "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
+ "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"camel-case": ["camel-case@4.1.2", "", { "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" } }, "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw=="],
- "caniuse-lite": ["caniuse-lite@1.0.30001764", "", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="],
+ "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
- "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
-
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+ "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
+
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
- "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
+ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
- "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="],
+ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
- "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
+ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
- "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
+ "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
+
+ "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
- "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
+ "cmake-js": ["cmake-js@8.0.0", "", { "dependencies": { "debug": "^4.4.3", "fs-extra": "^11.3.3", "node-api-headers": "^1.8.0", "rc": "1.2.8", "semver": "^7.7.3", "tar": "^7.5.6", "url-join": "^4.0.1", "which": "^6.0.0", "yargs": "^17.7.2" }, "bin": { "cmake-js": "bin/cmake-js" } }, "sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg=="],
- "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
+ "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
- "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
-
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
- "comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
-
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
- "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
-
- "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
+ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"component-register": ["component-register@0.8.8", "", {}, "sha512-djhwcxjY+X9dacaYUEOkOm7tda8uOEDiMDigWysu3xv54M8o6XDlsjR1qt5Y8QLGiKg51fqXFIR2HUTmt9ys0Q=="],
- "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
+ "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
@@ -757,52 +847,30 @@
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
"css-select": ["css-select@4.3.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.0.1", "domhandler": "^4.3.1", "domutils": "^2.8.0", "nth-check": "^2.0.1" } }, "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ=="],
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
- "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
-
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
- "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
-
- "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
+ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
- "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
+ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
- "dependency-tree": ["dependency-tree@11.2.0", "", { "dependencies": { "commander": "^12.1.0", "filing-cabinet": "^5.0.3", "precinct": "^12.2.0", "typescript": "^5.8.3" }, "bin": { "dependency-tree": "bin/cli.js" } }, "sha512-+C1H3mXhcvMCeu5i2Jpg9dc0N29TWTuT6vJD7mHLAfVmAbo9zW8NlkvQ1tYd3PDMab0IRQM0ccoyX68EZtx9xw=="],
+ "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
- "detective-amd": ["detective-amd@6.0.1", "", { "dependencies": { "ast-module-types": "^6.0.1", "escodegen": "^2.1.0", "get-amd-module-type": "^6.0.1", "node-source-walk": "^7.0.1" }, "bin": { "detective-amd": "bin/cli.js" } }, "sha512-TtyZ3OhwUoEEIhTFoc1C9IyJIud3y+xYkSRjmvCt65+ycQuc3VcBrPRTMWoO/AnuCyOB8T5gky+xf7Igxtjd3g=="],
-
- "detective-cjs": ["detective-cjs@6.0.1", "", { "dependencies": { "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" } }, "sha512-tLTQsWvd2WMcmn/60T2inEJNhJoi7a//PQ7DwRKEj1yEeiQs4mrONgsUtEJKnZmrGWBBmE0kJ1vqOG/NAxwaJw=="],
-
- "detective-es6": ["detective-es6@5.0.1", "", { "dependencies": { "node-source-walk": "^7.0.1" } }, "sha512-XusTPuewnSUdoxRSx8OOI6xIA/uld/wMQwYsouvFN2LAg7HgP06NF1lHRV3x6BZxyL2Kkoih4ewcq8hcbGtwew=="],
-
- "detective-postcss": ["detective-postcss@7.0.1", "", { "dependencies": { "is-url": "^1.2.4", "postcss-values-parser": "^6.0.2" }, "peerDependencies": { "postcss": "^8.4.47" } }, "sha512-bEOVpHU9picRZux5XnwGsmCN4+8oZo7vSW0O0/Enq/TO5R2pIAP2279NsszpJR7ocnQt4WXU0+nnh/0JuK4KHQ=="],
-
- "detective-sass": ["detective-sass@6.0.1", "", { "dependencies": { "gonzales-pe": "^4.3.0", "node-source-walk": "^7.0.1" } }, "sha512-jSGPO8QDy7K7pztUmGC6aiHkexBQT4GIH+mBAL9ZyBmnUIOFbkfZnO8wPRRJFP/QP83irObgsZHCoDHZ173tRw=="],
-
- "detective-scss": ["detective-scss@5.0.1", "", { "dependencies": { "gonzales-pe": "^4.3.0", "node-source-walk": "^7.0.1" } }, "sha512-MAyPYRgS6DCiS6n6AoSBJXLGVOydsr9huwXORUlJ37K3YLyiN0vYHpzs3AdJOgHobBfispokoqrEon9rbmKacg=="],
-
- "detective-stylus": ["detective-stylus@5.0.1", "", {}, "sha512-Dgn0bUqdGbE3oZJ+WCKf8Dmu7VWLcmRJGc6RCzBgG31DLIyai9WAoEhYRgIHpt/BCRMrnXLbGWGPQuBUrnF0TA=="],
-
- "detective-typescript": ["detective-typescript@14.0.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "^8.23.0", "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" }, "peerDependencies": { "typescript": "^5.4.4" } }, "sha512-pgN43/80MmWVSEi5LUuiVvO/0a9ss5V7fwVfrJ4QzAQRd3cwqU1SfWGXJFcNKUqoD5cS+uIovhw5t/0rSeC5Mw=="],
-
- "detective-vue2": ["detective-vue2@2.2.0", "", { "dependencies": { "@dependents/detective-less": "^5.0.1", "@vue/compiler-sfc": "^3.5.13", "detective-es6": "^5.0.1", "detective-sass": "^6.0.1", "detective-scss": "^5.0.1", "detective-stylus": "^5.0.1", "detective-typescript": "^14.0.0" }, "peerDependencies": { "typescript": "^5.4.4" } }, "sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA=="],
-
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
- "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
-
"dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -817,88 +885,82 @@
"dotenv-expand": ["dotenv-expand@8.0.3", "", {}, "sha512-SErOMvge0ZUyWd5B0NXMQlDkN+8r+HhVUsxgOO7IoPDOdDRD2JjExpN6y3KnFR66jsJMwSn1pqIivhU5rcJiNg=="],
+ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
+
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
- "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
+ "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
- "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
+ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
- "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+ "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
- "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
-
- "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
-
- "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
+ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
- "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+ "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
- "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
+ "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
- "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
+ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
- "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
+ "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
- "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
- "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+ "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
- "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+ "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
- "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
+ "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
- "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
+ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
+ "fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="],
+
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
- "filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="],
+ "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
- "filing-cabinet": ["filing-cabinet@5.0.3", "", { "dependencies": { "app-module-path": "^2.2.0", "commander": "^12.1.0", "enhanced-resolve": "^5.18.0", "module-definition": "^6.0.1", "module-lookup-amd": "^9.0.3", "resolve": "^1.22.10", "resolve-dependency-path": "^4.0.1", "sass-lookup": "^6.1.0", "stylus-lookup": "^6.1.0", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3" }, "bin": { "filing-cabinet": "bin/cli.js" } }, "sha512-PlPcMwVWg60NQkhvfoxZs4wEHjhlOO/y7OAm4sKM60o1Z9nttRY4mcdQxp/iZ+kg/Vv6Hw1OAaTbYVM9DA9pYg=="],
+ "filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
- "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
+ "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
- "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
+ "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
- "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
- "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+ "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
- "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
- "get-amd-module-type": ["get-amd-module-type@6.0.1", "", { "dependencies": { "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" } }, "sha512-MtjsmYiCXcYDDrGqtNbeIYdAl85n+5mSv2r3FbzER/YV3ZILw4HNNIw34HuV5pyl0jzs6GFYU1VHVEefhgcNHQ=="],
+ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
- "get-own-enumerable-property-symbols": ["get-own-enumerable-property-symbols@3.0.2", "", {}, "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g=="],
-
- "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
+ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
- "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+ "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
- "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
+ "glob": ["glob@13.0.3", "", { "dependencies": { "minimatch": "^10.2.0", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-/g3B0mC+4x724v1TgtBlBtt2hPi/EWptsIAmXUx9Z2rvBYleQcsrmaOzd5LyL50jf/Soi83ZDJmw2+XqvH/EeA=="],
- "gonzales-pe": ["gonzales-pe@4.3.0", "", { "dependencies": { "minimist": "^1.2.5" }, "bin": { "gonzales": "bin/gonzales.js" } }, "sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ=="],
+ "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
- "happy-dom": ["happy-dom@20.1.0", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-ebvqjBqzenBk2LjzNEAzoj7yhw7rW/R2/wVevMu6Mrq3MXtcI/RUz4+ozpcOcqVLEWPqLfg2v9EAU7fFXZUUJw=="],
-
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
- "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
-
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
@@ -909,6 +971,8 @@
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
+ "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
+
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
"html-minifier-terser": ["html-minifier-terser@6.1.0", "", { "dependencies": { "camel-case": "^4.1.2", "clean-css": "^5.2.2", "commander": "^8.3.0", "he": "^1.2.0", "param-case": "^3.0.4", "relateurl": "^0.2.7", "terser": "^5.10.0" }, "bin": { "html-minifier-terser": "cli.js" } }, "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw=="],
@@ -917,17 +981,25 @@
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
- "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
+ "ignore": ["ignore@6.0.2", "", {}, "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A=="],
- "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
+ "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
- "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
+ "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
+
+ "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
+
+ "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
+
+ "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
- "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
+ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
+
+ "is-empty": ["is-empty@1.2.0", "", {}, "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -935,21 +1007,17 @@
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
- "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="],
+ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
- "is-obj": ["is-obj@1.0.1", "", {}, "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg=="],
-
- "is-regexp": ["is-regexp@1.0.0", "", {}, "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA=="],
-
- "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
+ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
- "is-url": ["is-url@1.2.4", "", {}, "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="],
+ "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
- "is-url-superb": ["is-url-superb@4.0.0", "", {}, "sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA=="],
+ "isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
- "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
+ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
@@ -957,10 +1025,12 @@
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
- "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
+ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
+ "json-parse-even-better-errors": ["json-parse-even-better-errors@3.0.2", "", {}, "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ=="],
+
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
@@ -991,49 +1061,103 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
- "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
+ "lines-and-columns": ["lines-and-columns@2.0.4", "", {}, "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A=="],
- "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
+ "load-plugin": ["load-plugin@6.0.3", "", { "dependencies": { "@npmcli/config": "^8.0.0", "import-meta-resolve": "^4.0.0" } }, "sha512-kc0X2FEUZr145odl68frm+lMJuQ23+rTXYmR6TImqPtbpmXC4vVXbWKDQ9IzndA0HfyQamWfKLhzsqGSTxE63w=="],
- "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="],
-
- "lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
+ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
- "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+ "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="],
- "madge": ["madge@8.0.0", "", { "dependencies": { "chalk": "^4.1.2", "commander": "^7.2.0", "commondir": "^1.0.1", "debug": "^4.3.4", "dependency-tree": "^11.0.0", "ora": "^5.4.1", "pluralize": "^8.0.0", "pretty-ms": "^7.0.1", "rc": "^1.2.8", "stream-to-array": "^2.3.0", "ts-graphviz": "^2.1.2", "walkdir": "^0.4.1" }, "peerDependencies": { "typescript": "^5.4.4" }, "optionalPeers": ["typescript"], "bin": { "madge": "bin/cli.js" } }, "sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw=="],
+ "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="],
+ "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
+
+ "mdast-comment-marker": ["mdast-comment-marker@3.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-mdx-expression": "^2.0.0" } }, "sha512-bt08sLmTNg00/UtVDiqZKocxqvQqqyQZAg1uaRuO/4ysXV5motg7RolF5o5yy/sY1rG0v2XgZEqFWho1+2UquA=="],
+
+ "mdast-util-directive": ["mdast-util-directive@3.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q=="],
+
+ "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
+
+ "mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="],
+
+ "mdast-util-heading-style": ["mdast-util-heading-style@3.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-tsUfM9Kj9msjlemA/38Z3pvraQay880E3zP2NgIthMoGcpU9bcPX9oSM6QC/+eFXGGB4ba+VCB1dKAPHB7Veug=="],
+
+ "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="],
+
+ "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
+
+ "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
+
+ "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
+
+ "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
+
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
+ "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
+
+ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
+
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
+ "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
+
+ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
+
+ "micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="],
+
+ "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
+
+ "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
+
+ "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
+
+ "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
+
+ "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
+
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
+ "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
+
+ "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
+
+ "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
+
+ "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
+
+ "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
+
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
+ "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
+
+ "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
+
+ "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
+
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
+ "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
+
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
- "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
-
- "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
+ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
- "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+ "miniflare": ["miniflare@4.20260212.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260212.0", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-Lgxq83EuR2q/0/DAVOSGXhXS1V7GDB04HVggoPsenQng8sqEDR3hO4FigIw5ZI2Sv2X7kIc30NCzGHJlCFIYWg=="],
- "miniflare": ["miniflare@4.20260107.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20260107.1", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "^3.25.76" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-X93sXczqbBq9ixoM6jnesmdTqp+4baVC/aM/DuPpRS0LK0XtcqaO75qPzNEvDEzBAHxwMAWRIum/9hg32YB8iA=="],
-
- "minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
+ "minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1041,57 +1165,75 @@
"minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="],
- "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
+ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
- "module-definition": ["module-definition@6.0.1", "", { "dependencies": { "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" }, "bin": { "module-definition": "bin/cli.js" } }, "sha512-FeVc50FTfVVQnolk/WQT8MX+2WVcDnTGiq6Wo+/+lJ2ET1bRVi3HG3YlJUfqagNMc/kUlFSoR96AJkxGpKz13g=="],
+ "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
- "module-lookup-amd": ["module-lookup-amd@9.0.5", "", { "dependencies": { "commander": "^12.1.0", "glob": "^7.2.3", "requirejs": "^2.3.7", "requirejs-config-file": "^4.0.0" }, "bin": { "lookup-amd": "bin/cli.js" } }, "sha512-Rs5FVpVcBYRHPLuhHOjgbRhosaQYLtEo3JIeDIbmNo7mSssi1CTzwMh8v36gAzpbzLGXI9wB/yHh+5+3fY1QVw=="],
+ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"moq-doc": ["moq-doc@workspace:doc"],
+ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+ "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
+
"no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="],
+ "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="],
+
+ "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
+
+ "node-api-headers": ["node-api-headers@1.8.0", "", {}, "sha512-jfnmiKWjRAGbdD1yQS28bknFM1tbHC1oucyuMPjmkEs+kpiu76aRs40WlTmBmyEgzDM76ge1DQ7XJ3R5deiVjQ=="],
+
"node-html-parser": ["node-html-parser@5.4.2", "", { "dependencies": { "css-select": "^4.2.1", "he": "1.2.0" } }, "sha512-RaBPP3+51hPne/OolXxcz89iYvQvKOydaqoePpOgXcrOKZhjVIzmpKZz+Hd/RBO2/zN2q6CNJhQzucVz+u3Jyw=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
- "node-source-walk": ["node-source-walk@7.0.1", "", { "dependencies": { "@babel/parser": "^7.26.7" } }, "sha512-3VW/8JpPqPvnJvseXowjZcirPisssnBuDikk6JIZ8jQzF7KJQX52iPFX4RYYxLycYH7IbMRSPUOga/esVjy5Yg=="],
+ "nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
+
+ "normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="],
+
+ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
+
+ "npm-install-checks": ["npm-install-checks@6.3.0", "", { "dependencies": { "semver": "^7.1.1" } }, "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw=="],
+
+ "npm-normalize-package-bin": ["npm-normalize-package-bin@3.0.1", "", {}, "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ=="],
+
+ "npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="],
+
+ "npm-pick-manifest": ["npm-pick-manifest@9.1.0", "", { "dependencies": { "npm-install-checks": "^6.0.0", "npm-normalize-package-bin": "^3.0.0", "npm-package-arg": "^11.0.0", "semver": "^7.3.5" } }, "sha512-nkc+3pIIhqHVQr085X9d2JzPzLyjzQS96zbruppqC9aZRm/x8xx6xhI98gHtsfELP2bE+loHq8ZaHFHhe+NauA=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
- "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
-
"oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="],
- "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
-
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
+ "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
+
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
- "parse-ms": ["parse-ms@2.1.0", "", {}, "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA=="],
+ "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
+
+ "parse-json": ["parse-json@7.1.1", "", { "dependencies": { "@babel/code-frame": "^7.21.4", "error-ex": "^1.3.2", "json-parse-even-better-errors": "^3.0.0", "lines-and-columns": "^2.0.3", "type-fest": "^3.8.0" } }, "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="],
- "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
-
- "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
- "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
-
- "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
+ "pathe": ["pathe@0.2.0", "", {}, "sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
@@ -1105,29 +1247,31 @@
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
- "postcss-values-parser": ["postcss-values-parser@6.0.2", "", { "dependencies": { "color-name": "^1.1.4", "is-url-superb": "^4.0.0", "quote-unquote": "^1.0.0" }, "peerDependencies": { "postcss": "^8.2.9" } }, "sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw=="],
+ "preact": ["preact@10.28.3", "", {}, "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA=="],
- "preact": ["preact@10.28.2", "", {}, "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA=="],
+ "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
- "precinct": ["precinct@12.2.0", "", { "dependencies": { "@dependents/detective-less": "^5.0.1", "commander": "^12.1.0", "detective-amd": "^6.0.1", "detective-cjs": "^6.0.1", "detective-es6": "^5.0.1", "detective-postcss": "^7.0.1", "detective-sass": "^6.0.1", "detective-scss": "^5.0.1", "detective-stylus": "^5.0.1", "detective-typescript": "^14.0.0", "detective-vue2": "^2.2.0", "module-definition": "^6.0.1", "node-source-walk": "^7.0.1", "postcss": "^8.5.1", "typescript": "^5.7.3" }, "bin": { "precinct": "bin/cli.js" } }, "sha512-NFBMuwIfaJ4SocE9YXPU/n4AcNSoFMVFjP72nvl3cx69j/ke61/hPOWFREVxLkFhhEGnA8ZuVfTqJBa+PK3b5w=="],
+ "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="],
- "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
+ "promise-inflight": ["promise-inflight@1.0.1", "", {}, "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g=="],
- "pretty-ms": ["pretty-ms@7.0.1", "", { "dependencies": { "parse-ms": "^2.1.0" } }, "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q=="],
+ "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
- "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
+ "publint": ["publint@0.3.18", "", { "dependencies": { "@publint/pack": "^0.1.4", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, "bin": { "publint": "src/cli.js" } }, "sha512-JRJFeBTrfx4qLwEuGFPk+haJOJN97KnPuK01yj+4k/Wj5BgoOK5uNsivporiqBjk2JDaslg7qJOhGRnpltGeog=="],
+
+ "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
- "quote-unquote": ["quote-unquote@1.0.0", "", {}, "sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg=="],
+ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
- "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
+ "read-package-json-fast": ["read-package-json-fast@3.0.2", "", { "dependencies": { "json-parse-even-better-errors": "^3.0.0", "npm-normalize-package-bin": "^3.0.0" } }, "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
- "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
+ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
@@ -1137,17 +1281,83 @@
"relateurl": ["relateurl@0.2.7", "", {}, "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog=="],
- "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
+ "remark": ["remark@15.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A=="],
+
+ "remark-cli": ["remark-cli@12.0.1", "", { "dependencies": { "import-meta-resolve": "^4.0.0", "markdown-extensions": "^2.0.0", "remark": "^15.0.0", "unified-args": "^11.0.0" }, "bin": { "remark": "cli.js" } }, "sha512-2NAEOACoTgo+e+YAaCTODqbrWyhMVmlUyjxNCkTrDRHHQvH6+NbrnqVvQaLH/Q8Ket3v90A43dgAJmXv8y5Tkw=="],
+
+ "remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="],
+
+ "remark-lint": ["remark-lint@10.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "remark-message-control": "^8.0.0", "unified": "^11.0.0" } }, "sha512-1+PYGFziOg4pH7DDf1uMd4AR3YuO2EMnds/SdIWMPGT7CAfDRSnAmpxPsJD0Ds3IKpn97h3d5KPGf1WFOg6hXQ=="],
+
+ "remark-lint-blockquote-indentation": ["remark-lint-blockquote-indentation@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-phrasing": "^4.0.0", "pluralize": "^8.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-7BhOsImFgTD7IIliu2tt+yJbx5gbMbXCOspc3VdYf/87iLJdWKqJoMy2V6DZG7kBjBlBsIZi38fDDngJttXt4w=="],
- "requirejs": ["requirejs@2.3.8", "", { "bin": { "r.js": "bin/r.js", "r_js": "bin/r.js" } }, "sha512-7/cTSLOdYkNBNJcDMWf+luFvMriVm7eYxp4BcFCsAX0wF421Vyce5SXP17c+Jd5otXKGNehIonFlyQXSowL6Mw=="],
+ "remark-lint-checkbox-character-style": ["remark-lint-checkbox-character-style@5.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-phrasing": "^4.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-6qilm7XQXOcTvjFEqqNY57Ki7md9rkSdpMIfIzVXdEnI4Npl2BnUff6ANrGRM7qTgJTrloaf8H0eQ91urcU6Og=="],
- "requirejs-config-file": ["requirejs-config-file@4.0.0", "", { "dependencies": { "esprima": "^4.0.0", "stringify-object": "^3.2.1" } }, "sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw=="],
+ "remark-lint-code-block-style": ["remark-lint-code-block-style@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-phrasing": "^4.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-d4mHsEpv1yqXWl2dd+28tGRX0Lzk5qw7cfxAQVkOXPUONhsMFwXJEBeeqZokeG4lOKtkKdIJR7ezScDfWR0X4w=="],
- "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
+ "remark-lint-emphasis-marker": ["remark-lint-emphasis-marker@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-BF1WWsAxai3XoKk48sfiqT3L8m02AZLj3BnipWkHDRXuLfz6VwsHVaHWyNvvE0p6b2B3A5dSYbcfJu5RmPx4tQ=="],
- "resolve-dependency-path": ["resolve-dependency-path@4.0.1", "", {}, "sha512-YQftIIC4vzO9UMhO/sCgXukNyiwVRCVaxiWskCBy7Zpqkplm8kTAISZ8O1MoKW1ca6xzgLUBjZTcDgypXvXxiQ=="],
+ "remark-lint-fenced-code-marker": ["remark-lint-fenced-code-marker@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-phrasing": "^4.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-uI91OcVPKjNxV+vpjDW9T64hkE0a/CRn3JhwdMxUAJYpVsKnA7PFPSFJOx/abNsVZHNSe7ZFGgGdaH/lqgSizA=="],
- "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
+ "remark-lint-final-newline": ["remark-lint-final-newline@3.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "unified-lint-rule": "^3.0.0", "vfile-location": "^5.0.0" } }, "sha512-q5diKHD6BMbzqWqgvYPOB8AJgLrMzEMBAprNXjcpKoZ/uCRqly+gxjco+qVUMtMWSd+P+KXZZEqoa7Y6QiOudw=="],
+
+ "remark-lint-hard-break-spaces": ["remark-lint-hard-break-spaces@4.1.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-AKDPDt39fvmr3yk38OKZEWJxxCOOUBE+96AsBfs+ExS5LW6oLa9041X5ahFDQHvHGzdoremEIaaElursaPEkNg=="],
+
+ "remark-lint-heading-style": ["remark-lint-heading-style@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-heading-style": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-+rUpJ/N2CGC5xPgZ18XgsCsUBtadgEhdTi0BJPrsFmHPzL22BUHajeg9im8Y7zphUcbi1qFiKuxZd2nzDgZSXQ=="],
+
+ "remark-lint-link-title-style": ["remark-lint-link-title-style@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-MtmnYrhjhRXR0zeiyYf/7GBlUF5KAPypJb345KjyDluOhI4Wj4VAXvVQuov/MFc3y8p/1yVwv3QDYv6yue8/wQ=="],
+
+ "remark-lint-list-item-bullet-indent": ["remark-lint-list-item-bullet-indent@5.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "pluralize": "^8.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0" } }, "sha512-LKuTxkw5aYChzZoF3BkfaBheSCHs0T8n8dPHLQEuOLo6iC5wy98iyryz0KZ61GD8stlZgQO2KdWSdnP6vr40Iw=="],
+
+ "remark-lint-list-item-content-indent": ["remark-lint-list-item-content-indent@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-phrasing": "^4.0.0", "pluralize": "^8.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-KSopxxp64O6dLuTQ2sWaTqgjKWr1+AoB1QCTektMJ3mfHfn0QyZzC2CZbBU22KGzBhiYXv9cIxlJlxUtq2NqHg=="],
+
+ "remark-lint-list-item-indent": ["remark-lint-list-item-indent@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-phrasing": "^4.0.0", "pluralize": "^8.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-gJd1Q+jOAeTgmGRsdMpnRh01DUrAm0O5PCQxE8ttv1QZOV015p/qJH+B4N6QSmcUuPokHLAh9USuq05C73qpiA=="],
+
+ "remark-lint-maximum-line-length": ["remark-lint-maximum-line-length@4.1.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-mdx": "^3.0.0", "pluralize": "^8.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-oIncZkI0oIXZk+1kJOMnE3WPbyMTUbds0q1E8WbCwtjN9pAZsQD2e+wK+xdi5VqOLPkvLER+yzbmi/A3Tp+XEg=="],
+
+ "remark-lint-no-blockquote-without-marker": ["remark-lint-no-blockquote-without-marker@6.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-directive": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "pluralize": "^8.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-location": "^5.0.0" } }, "sha512-b4IOkNcG7C16HYAdKUeAhO7qPt45m+v7SeYbVrqvbSFtlD3EUBL8fgHRgLK1mdujFXDP1VguOEMx+Txv8JOT4w=="],
+
+ "remark-lint-no-duplicate-definitions": ["remark-lint-no-duplicate-definitions@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-phrasing": "^4.0.0", "unified-lint-rule": "^3.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-Ek+A/xDkv5Nn+BXCFmf+uOrFSajCHj6CjhsHjtROgVUeEPj726yYekDBoDRA0Y3+z+U30AsJoHgf/9Jj1IFSug=="],
+
+ "remark-lint-no-duplicate-headings": ["remark-lint-no-duplicate-headings@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-mdx": "^3.0.0", "mdast-util-to-string": "^4.0.0", "unified-lint-rule": "^3.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-6lggqnpIe5FepikjYF2me3ovKV4oD/rAz8WmwVbLR2cLkce1iH+PB7jyxk/A2gQQqrDcIlRMA5Ct2Yj56cEwhQ=="],
+
+ "remark-lint-no-heading-content-indent": ["remark-lint-no-heading-content-indent@5.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-phrasing": "^4.0.0", "pluralize": "^8.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-YIWktnZo7M9aw7PGnHdshvetSH3Y0qW+Fm143R66zsk5lLzn1XA5NEd/MtDzP8tSxxV+gcv+bDd5St1QUI4oSQ=="],
+
+ "remark-lint-no-literal-urls": ["remark-lint-no-literal-urls@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-character": "^2.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-RhTANFkFFXE6bM+WxWcPo2TTPEfkWG3lJZU50ycW7tJJmxUzDNzRed/z80EVJIdGwFa0NntVooLUJp3xrogalQ=="],
+
+ "remark-lint-no-shortcut-reference-image": ["remark-lint-no-shortcut-reference-image@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "unified-lint-rule": "^3.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-hQhJ3Dr8ZWRdj7qm6+9vcPpqtGchhENA2UHOmcTraLf6dN1cFATCgY/HbTbRIN6NkG/EEClTgRC1QCokWR2Mmw=="],
+
+ "remark-lint-no-shortcut-reference-link": ["remark-lint-no-shortcut-reference-link@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "unified-lint-rule": "^3.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-YxciuUZc90QaJYhayGO80lS3zxEOBgwwLW1MKYB7AfUdkrLcLVlS+DFloiq0MZ7EDVXuuGUEnIzyjyLSbI5BUA=="],
+
+ "remark-lint-no-undefined-references": ["remark-lint-no-undefined-references@5.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "micromark-util-normalize-identifier": "^2.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-location": "^5.0.0" } }, "sha512-5prkVb1tKwJwr5+kct/UjsLjvMdEDO7uClPeGfrxfAcN59+pWU8OUSYiqYmpSKWJPIdyxPRS8Oyf1HtaYvg8VQ=="],
+
+ "remark-lint-no-unused-definitions": ["remark-lint-no-unused-definitions@4.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "unified-lint-rule": "^3.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-KRzPmvfq6b3LSEcAQZobAn+5eDfPTle0dPyDEywgPSc3E7MIdRZQenL9UL8iIqHQWK4FvdUD0GX8FXGqu5EuCw=="],
+
+ "remark-lint-ordered-list-marker-style": ["remark-lint-ordered-list-marker-style@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-phrasing": "^4.0.0", "micromark-util-character": "^2.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-vZTAbstcBPbGwJacwldGzdGmKwy5/4r29SZ9nQkME4alEl5B1ReSBlYa8t7QnTSW7+tqvA9Sg71RPadgAKWa4w=="],
+
+ "remark-lint-ordered-list-marker-value": ["remark-lint-ordered-list-marker-value@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-phrasing": "^4.0.0", "micromark-util-character": "^2.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-HQb1MrArvApREC1/I6bkiFlZVDjngsuII29n8E8StnAaHOMN3hVYy6wJ9Uk+O3+X9O8v7fDsZPqFUHSfJhERXQ=="],
+
+ "remark-lint-rule-style": ["remark-lint-rule-style@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-phrasing": "^4.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-gl1Ft13oTS3dJUCsWZzxD/5dAwI1HON67KU7uNfODD5gXJ8Y11deOWbun190ma7XbYdD7P0l8VT2HeRtEQzrWg=="],
+
+ "remark-lint-strong-marker": ["remark-lint-strong-marker@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-KaGtj/OWEP4eoafevnlp3NsEVwC7yGEjBJ6uFMzfjNoXyjATdfZ2euB/AfKVt/A/FdZeeMeVoAUFH4DL+hScLQ=="],
+
+ "remark-lint-table-cell-padding": ["remark-lint-table-cell-padding@5.1.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "mdast-util-phrasing": "^4.0.0", "pluralize": "^8.0.0", "unified-lint-rule": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit-parents": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-6fgVA1iINBoAJaZMOnSsxrF9Qj9+hmCqrsrqZqgJJETjT1ODGH64iAN1/6vHR7dIwmy73d6ysB2WrGyKhVlK3A=="],
+
+ "remark-message-control": ["remark-message-control@8.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-comment-marker": "^3.0.0", "unified-message-control": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-brpzOO+jdyE/mLqvqqvbogmhGxKygjpCUCG/PwSCU43+JZQ+RM+sSzkCWBcYvgF3KIAVNIoPsvXjBkzO7EdsYQ=="],
+
+ "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
+
+ "remark-preset-lint-consistent": ["remark-preset-lint-consistent@6.0.1", "", { "dependencies": { "remark-lint": "^10.0.0", "remark-lint-blockquote-indentation": "^4.0.0", "remark-lint-checkbox-character-style": "^5.0.0", "remark-lint-code-block-style": "^4.0.0", "remark-lint-emphasis-marker": "^4.0.0", "remark-lint-fenced-code-marker": "^4.0.0", "remark-lint-heading-style": "^4.0.0", "remark-lint-link-title-style": "^4.0.0", "remark-lint-list-item-content-indent": "^4.0.0", "remark-lint-ordered-list-marker-style": "^4.0.0", "remark-lint-ordered-list-marker-value": "^4.0.0", "remark-lint-rule-style": "^4.0.0", "remark-lint-strong-marker": "^4.0.0", "remark-lint-table-cell-padding": "^5.0.0", "unified": "^11.0.0" } }, "sha512-SOLdA36UOU1hiGFm6HAqN9+DORGJPVWxU/EvPVkknTr9V4ULhlzHEJ8OVRMVX3jqoy4lrwb4IqiboVz0YLA7+Q=="],
+
+ "remark-preset-lint-recommended": ["remark-preset-lint-recommended@7.0.1", "", { "dependencies": { "remark-lint": "^10.0.0", "remark-lint-final-newline": "^3.0.0", "remark-lint-hard-break-spaces": "^4.0.0", "remark-lint-list-item-bullet-indent": "^5.0.0", "remark-lint-list-item-indent": "^4.0.0", "remark-lint-no-blockquote-without-marker": "^6.0.0", "remark-lint-no-duplicate-definitions": "^4.0.0", "remark-lint-no-heading-content-indent": "^5.0.0", "remark-lint-no-literal-urls": "^4.0.0", "remark-lint-no-shortcut-reference-image": "^4.0.0", "remark-lint-no-shortcut-reference-link": "^4.0.0", "remark-lint-no-undefined-references": "^5.0.0", "remark-lint-no-unused-definitions": "^4.0.0", "remark-lint-ordered-list-marker-style": "^4.0.0", "unified": "^11.0.0" } }, "sha512-j1CY5u48PtZl872BQ40uWSQMT3R4gXKp0FUgevMu5gW7hFMtvaCiDq+BfhzeR8XKKiW9nIMZGfIMZHostz5X4g=="],
+
+ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
+
+ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
+
+ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
+
+ "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
@@ -1155,39 +1365,43 @@
"rimraf": ["rimraf@6.1.2", "", { "dependencies": { "glob": "^13.0.0", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g=="],
- "rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="],
+ "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
- "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+ "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
- "sass-lookup": ["sass-lookup@6.1.0", "", { "dependencies": { "commander": "^12.1.0", "enhanced-resolve": "^5.18.0" }, "bin": { "sass-lookup": "bin/cli.js" } }, "sha512-Zx+lVyoWqXZxHuYWlTA17Z5sczJ6braNT2C7rmClw+c4E7r/n911Zwss3h1uHI9reR5AgHZyNHF7c2+VIp5AUA=="],
+ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
- "seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="],
+ "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
- "seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
+ "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
- "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
+ "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
+
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="],
- "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
- "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
+ "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
- "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
+ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
"solid-element": ["solid-element@1.9.1", "", { "dependencies": { "component-register": "^0.8.7" }, "peerDependencies": { "solid-js": "^1.9.3" } }, "sha512-baJy6Qz27oAUgkPlqOf3Y+7RsBiuVQrS51Nrh1ddDbrqrNPvJbIvehpUsTzLNFb2ZHIoHuNnDg330go/ZKcRdg=="],
- "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
+ "solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
@@ -1199,61 +1413,51 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
- "speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
+ "spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="],
- "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
+ "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="],
- "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
+ "spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="],
- "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="],
+ "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="],
- "stream-to-array": ["stream-to-array@2.3.0", "", { "dependencies": { "any-promise": "^1.1.0" } }, "sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA=="],
+ "speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+ "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
- "stringify-object": ["stringify-object@3.3.0", "", { "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", "is-obj": "^1.0.1", "is-regexp": "^1.0.0" } }, "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw=="],
-
- "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+ "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
- "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
-
- "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
+ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
- "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
-
- "stylus-lookup": ["stylus-lookup@6.1.0", "", { "dependencies": { "commander": "^12.1.0" }, "bin": { "stylus-lookup": "bin/cli.js" } }, "sha512-5QSwgxAzXPMN+yugy61C60PhoANdItfdjSEZR8siFwz7yL9jTmV0UBKDCfn3K8GkGB4g0Y9py7vTCX8rFu4/pQ=="],
-
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
- "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
-
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
- "terser": ["terser@5.44.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw=="],
-
- "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
+ "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="],
- "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
+ "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
- "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
- "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
+ "terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="],
- "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
+ "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="],
- "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
+ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
@@ -1261,49 +1465,73 @@
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
- "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
+ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
- "ts-graphviz": ["ts-graphviz@2.1.6", "", { "dependencies": { "@ts-graphviz/adapter": "^2.0.6", "@ts-graphviz/ast": "^2.0.7", "@ts-graphviz/common": "^2.1.5", "@ts-graphviz/core": "^2.0.7" } }, "sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw=="],
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
- "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="],
+ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
- "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+ "type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
- "undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
+ "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
+
+ "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
+
+ "undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
+ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
+
+ "unified-args": ["unified-args@11.0.1", "", { "dependencies": { "@types/text-table": "^0.2.0", "chalk": "^5.0.0", "chokidar": "^3.0.0", "comma-separated-tokens": "^2.0.0", "json5": "^2.0.0", "minimist": "^1.0.0", "strip-ansi": "^7.0.0", "text-table": "^0.2.0", "unified-engine": "^11.0.0" } }, "sha512-WEQghE91+0s3xPVs0YW6a5zUduNLjmANswX7YbBfksHNDGMjHxaWCql4SR7c9q0yov/XiIEdk6r/LqfPjaYGcw=="],
+
+ "unified-engine": ["unified-engine@11.2.2", "", { "dependencies": { "@types/concat-stream": "^2.0.0", "@types/debug": "^4.0.0", "@types/is-empty": "^1.0.0", "@types/node": "^22.0.0", "@types/unist": "^3.0.0", "concat-stream": "^2.0.0", "debug": "^4.0.0", "extend": "^3.0.0", "glob": "^10.0.0", "ignore": "^6.0.0", "is-empty": "^1.0.0", "is-plain-obj": "^4.0.0", "load-plugin": "^6.0.0", "parse-json": "^7.0.0", "trough": "^2.0.0", "unist-util-inspect": "^8.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0", "vfile-reporter": "^8.0.0", "vfile-statistics": "^3.0.0", "yaml": "^2.0.0" } }, "sha512-15g/gWE7qQl9tQ3nAEbMd5h9HV1EACtFs6N9xaRBZICoCwnNGbal1kOs++ICf4aiTdItZxU2s/kYWhW7htlqJg=="],
+
+ "unified-lint-rule": ["unified-lint-rule@3.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "trough": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-HxIeQOmwL19DGsxHXbeyzKHBsoSCFO7UtRVUvT2v61ptw/G+GbysWcrpHdfs5jqbIFDA11MoKngIhQK0BeTVjA=="],
+
+ "unified-message-control": ["unified-message-control@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "devlop": "^1.0.0", "space-separated-tokens": "^2.0.0", "unist-util-is": "^6.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-B2cSAkpuMVVmPP90KCfKdBhm1e9KYJ+zK3x5BCa0N65zpq1Ybkc9C77+M5qwR8FWO7RF3LM5QRRPZtgjW6DUCw=="],
+
+ "unist-util-inspect": ["unist-util-inspect@8.1.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-mOlg8Mp33pR0eeFpo5d2902ojqFFOKMMG2hF8bmH7ZlhnmjFgh0NI3/ZDwdaBJNbvrS7LZFVrBVtIE9KZ9s7vQ=="],
+
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
- "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
+ "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
- "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
-
- "unplugin-solid": ["unplugin-solid@1.0.0", "", { "dependencies": { "@babel/core": "^7.28.3", "@rollup/pluginutils": "^5.2.0", "babel-preset-solid": "^1.9.9", "merge-anything": "^6.0.6", "solid-refresh": "^0.7.5", "unplugin": "^2.3.10", "vitefu": "^1.1.1" }, "peerDependencies": { "solid-js": "^1.9.9" } }, "sha512-pv1CS3XMtf3WwX8Dq9Bvo4qH6mfjN2xOgbaPcnqW1dLhyP/JQCvueGEsN0dYIZ4JvxaD/G/Ot1JnBzNQGHkfeA=="],
-
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
+ "url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="],
+
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+ "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="],
+
+ "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="],
+
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+ "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
+
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
- "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
+ "vfile-reporter": ["vfile-reporter@8.1.1", "", { "dependencies": { "@types/supports-color": "^8.0.0", "string-width": "^6.0.0", "supports-color": "^9.0.0", "unist-util-stringify-position": "^4.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0", "vfile-sort": "^4.0.0", "vfile-statistics": "^3.0.0" } }, "sha512-qxRZcnFSQt6pWKn3PAk81yLK2rO2i7CDXpy8v8ZquiEOMLSnPw6BMSi9Y1sUCwGGl7a9b3CJT1CKpnRF7pp66g=="],
+
+ "vfile-sort": ["vfile-sort@4.0.0", "", { "dependencies": { "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-lffPI1JrbHDTToJwcq0rl6rBmkjQmMuXkAxsZPRS9DXbaJQvc642eCg6EGxcX2i1L+esbuhq+2l9tBll5v8AeQ=="],
+
+ "vfile-statistics": ["vfile-statistics@3.0.0", "", { "dependencies": { "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-/qlwqwWBWFOmpXujL/20P+Iuydil0rZZNglR+VNm6J0gpLHwuVM5s7g2TfVoswbXjZ4HuIhLMySEyIw5i7/D8w=="],
- "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
+ "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite-plugin-html": ["vite-plugin-html@3.2.2", "", { "dependencies": { "@rollup/pluginutils": "^4.2.0", "colorette": "^2.0.16", "connect-history-api-fallback": "^1.6.0", "consola": "^2.15.3", "dotenv": "^16.0.0", "dotenv-expand": "^8.0.2", "ejs": "^3.1.6", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "html-minifier-terser": "^6.1.0", "node-html-parser": "^5.3.3", "pathe": "^0.2.0" }, "peerDependencies": { "vite": ">=2.0.0" } }, "sha512-vb9C9kcdzcIo/Oc3CLZVS03dL5pDlOFuhGlZYDCJ840BhWl/0nGeZWf3Qy7NlOayscY4Cm/QRgULCQkEZige5Q=="],
@@ -1313,33 +1541,29 @@
"vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3", "postcss"], "bin": { "vitepress": "bin/vitepress.js" } }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="],
- "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
+ "vue": ["vue@3.5.28", "", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", "@vue/runtime-dom": "3.5.28", "@vue/server-renderer": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg=="],
- "vue": ["vue@3.5.26", "", { "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", "@vue/runtime-dom": "3.5.26", "@vue/server-renderer": "3.5.26", "@vue/shared": "3.5.26" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA=="],
+ "walk-up-path": ["walk-up-path@3.0.1", "", {}, "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA=="],
- "walkdir": ["walkdir@0.4.1", "", {}, "sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ=="],
+ "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
- "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
+ "workerd": ["workerd@1.20260212.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260212.0", "@cloudflare/workerd-darwin-arm64": "1.20260212.0", "@cloudflare/workerd-linux-64": "1.20260212.0", "@cloudflare/workerd-linux-arm64": "1.20260212.0", "@cloudflare/workerd-windows-64": "1.20260212.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-4B9BoZUzKSRv3pVZGEPh7OX+Q817hpUqAUtz5O0TxJVqo4OsYJAUA/sY177Q5ha/twjT9KaJt2DtQzE+oyCOzw=="],
- "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
-
- "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
-
- "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
-
- "workerd": ["workerd@1.20260107.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260107.1", "@cloudflare/workerd-darwin-arm64": "1.20260107.1", "@cloudflare/workerd-linux-64": "1.20260107.1", "@cloudflare/workerd-linux-arm64": "1.20260107.1", "@cloudflare/workerd-windows-64": "1.20260107.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-4ylAQJDdJZdMAUl2SbJgTa77YHpa88l6qmhiuCLNactP933+rifs7I0w1DslhUIFgydArUX5dNLAZnZhT7Bh7g=="],
-
- "wrangler": ["wrangler@4.58.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.1", "@cloudflare/unenv-preset": "2.8.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.0", "miniflare": "4.20260107.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260107.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260107.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-Jm6EYtlt8iUcznOCPSMYC54DYkwrMNESzbH0Vh3GFHv/7XVw5gBC13YJAB+nWMRGJ+6B2dMzy/NVQS4ONL51Pw=="],
+ "wrangler": ["wrangler@4.65.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.12.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260212.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260212.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260212.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-R+n3o3tlGzLK9I4fGocPReOuvcnjhtOL2aCVKkHMeuEwt9pPbOO4FxJtx/ec5cIUG/otRyJnfQGCAr9DplBVng=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
+ "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
+
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
- "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
+ "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
- "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
+
+ "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -1349,283 +1573,237 @@
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
- "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
+ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
- "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
-
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
- "@moq/hang-ui/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
-
- "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
-
- "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
-
- "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
-
- "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
-
- "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
-
- "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
-
- "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
-
- "@vue/compiler-core/entities": ["entities@7.0.0", "", {}, "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ=="],
-
- "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
-
- "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
-
- "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
-
- "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
-
- "copy-anything/is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
-
- "dependency-tree/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
-
- "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
+ "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
- "filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
+ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
- "filing-cabinet/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
+ "@npmcli/config/ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="],
- "happy-dom/@types/node": ["@types/node@20.19.28", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw=="],
+ "@npmcli/config/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
- "html-minifier-terser/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
+ "@npmcli/git/ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="],
- "madge/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
+ "@npmcli/git/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
- "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+ "@npmcli/git/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
- "miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
+ "@npmcli/git/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
- "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
+ "@npmcli/map-workspaces/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
- "miniflare/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+ "@npmcli/map-workspaces/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
- "module-lookup-amd/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
+ "@npmcli/package-json/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
- "module-lookup-amd/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
+ "@npmcli/package-json/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
- "precinct/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
+ "@npmcli/promise-spawn/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
- "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
-
- "sass-lookup/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
-
- "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
+ "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
- "stylus-lookup/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
+ "@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
- "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
- "unplugin-solid/merge-anything": ["merge-anything@6.0.6", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-F3K1W45PvTjRZzbcYIhXntNr8cux00gUxR8IzNPPG+80gNlAHZGVBwFyN4x5yjw/7QkLPKDbRQBK4KrJKo69mw=="],
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
- "unplugin-solid/solid-refresh": ["solid-refresh@0.7.5", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-ZYMbjWsy7IwSF3+oZCNnReiTYSyCAFRvC7oLUKxxh1wPa6/6YIWqsxa+Ma2kM4F/ypWT69B1c0fmKeZRdLueGw=="],
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
- "vite-node/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
- "vite-plugin-html/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
- "vite-plugin-html/pathe": ["pathe@0.2.0", "", {}, "sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw=="],
+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
- "vitepress/vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
+ "@vitejs/plugin-vue/vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
- "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
+ "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
- "wrangler/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
+ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
- "@moq/hang-ui/vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
+ "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
- "happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+ "balanced-match/jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
- "module-lookup-amd/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
+ "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
- "unplugin-solid/merge-anything/is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
+ "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "vite-node/vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
+ "cmake-js/fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="],
- "vite-plugin-html/@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
+ "cmake-js/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
- "vite-plugin-html/@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+ "copy-anything/is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
- "vitepress/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
+ "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
- "vitest/vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
+ "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
- "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
+ "filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
- "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="],
+ "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
- "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="],
+ "html-minifier-terser/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
- "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="],
+ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
- "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="],
+ "node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
- "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="],
+ "normalize-package-data/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
- "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="],
+ "npm-install-checks/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
- "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="],
+ "npm-package-arg/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
- "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="],
+ "npm-pick-manifest/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
- "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="],
+ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
- "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="],
+ "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
- "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="],
+ "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
- "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="],
+ "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="],
+ "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="],
+ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="],
+ "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
- "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="],
+ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
- "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="],
+ "unenv/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
- "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="],
+ "unified-args/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
- "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="],
+ "unified-engine/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
- "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="],
+ "unified-engine/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
- "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="],
+ "vfile-reporter/string-width": ["string-width@6.1.0", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^10.2.1", "strip-ansi": "^7.0.1" } }, "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ=="],
- "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="],
+ "vfile-reporter/supports-color": ["supports-color@9.4.0", "", {}, "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw=="],
- "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="],
+ "vitepress/vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
- "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="],
+ "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
+ "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
+ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
+ "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
+ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
+ "@npmcli/git/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
+ "@npmcli/map-workspaces/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
+ "@npmcli/map-workspaces/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
+ "@npmcli/package-json/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
+ "@npmcli/package-json/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
+ "@npmcli/promise-spawn/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
+ "@vitejs/plugin-vue/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
+ "balanced-match/jackspeak/@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
+ "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
+ "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
+ "filelist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
+ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
+ "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
+ "unified-engine/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
+ "unified-engine/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
+ "unified-engine/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
+ "vfile-reporter/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
+ "vitepress/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
+ "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
+ "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
+ "@npmcli/map-workspaces/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
+ "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
- "@moq/hang-ui/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
+ "@npmcli/package-json/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
- "module-lookup-amd/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
+ "@npmcli/package-json/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
- "vite-node/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
- "vite-node/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
- "vite-node/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
- "vite-node/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
- "vite-node/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
- "vite-node/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
- "vite-node/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
- "vite-node/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
- "vite-node/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
- "vite-node/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
- "vite-node/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
- "vite-node/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
- "vite-node/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
- "vite-node/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
- "vite-node/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
- "vite-node/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
- "vite-node/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
- "vite-node/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
- "vite-node/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
- "vite-node/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
- "vite-node/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
- "vite-node/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
- "vite-node/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
+ "@vitejs/plugin-vue/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
- "vite-node/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
+ "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
- "vite-node/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
+ "unified-engine/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
- "vite-node/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
+ "unified-engine/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"vitepress/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
@@ -1673,56 +1851,8 @@
"vitepress/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
- "vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
-
- "vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
-
- "vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
-
- "vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
-
- "vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
-
- "vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
-
- "vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
-
- "vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
-
- "vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
-
- "vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
-
- "vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
-
- "vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
-
- "vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
-
- "vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
-
- "vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
-
- "vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
-
- "vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
-
- "vitest/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
-
- "vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
-
- "vitest/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
-
- "vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
-
- "vitest/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
-
- "vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
-
- "vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
-
- "vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
+ "@npmcli/package-json/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
- "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
+ "unified-engine/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
}
}
diff --git a/cdn/.gitignore b/cdn/.gitignore
deleted file mode 100644
index a3749935f9..0000000000
--- a/cdn/.gitignore
+++ /dev/null
@@ -1,23 +0,0 @@
-# Terraform/OpenTofu
-.terraform/
-.tofu/
-.terraform.lock.hcl
-.tofu.lock.hcl
-terraform.tfstate
-terraform.tfstate.backup
-tofu.tfstate
-tofu.tfstate.backup
-*.tfvars
-!*.tfvars.example
-
-# Nix
-result
-result-*
-
-# Credentials
-secrets/*
-*.jwk
-*.jwt
-*.key
-*.crt
-*.pem
diff --git a/cdn/README.md b/cdn/README.md
deleted file mode 100644
index 8c39c67861..0000000000
--- a/cdn/README.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# Infrastructure
-
-OpenTofu/Terraform configuration for deploying clustered MoQ relays to Linode.
-There's nothing special about Linode, other cloud providers will work provided they support UDP and public IPs.
-
-However, we do use GCP for GeoDNS because most providers don't support it or too expensive (Cloudflare).
-
-## Setup
-
-1. Copy `terraform.tfvars.example` to `terraform.tfvars` and fill in values
-2. Run `tofu init`.
-3. Run `tofu apply`.
-4. Create a `secrets/` directory with JWT/JWK credentials:
- - ```bash
- mkdir -p secrets
-
- # generate the root key private key
- cargo run --bin moq-token -- --key secrets/root.jwk generate > secrets/root.jwk
-
- # to allow relay servers to connect to each other
- cargo run --bin moq-token -- --key secrets/root.jwk sign --publish "" --subscribe "" --cluster > secrets/cluster.jwt
-
- # to allow publishing to `demo/`
- cargo run --bin moq-token -- --key secrets/root.jwk sign --root "demo" --publish "" > secrets/demo-pub.jwt
- ```
-
-## Deploy
-
-1. `nix flake update` to update the `moq-relay` and `hang-cli` binaries.
- - **NOTE**: This pulls from `main` on github, not a local path or the latest release.
-2. `just deploy-all` to deploy to all nodes in parallel.
- - This will take a while as the builds *currently* occur on the remote nodes.
- - Somebody should set up remote builders or cross-compilation.
-
-## Monitor
-Use `just` to see all of the available commands.
-
-1. `just ssh ` to SSH into a specific node.
-2. `just logs ` to view the logs of a specific node.
-3. etc
-
-## Costs
-Change the number of nodes in [input.tf](input.tf).
-
-- $25/month for `g6-standard-2` nodes.
-- $5/month for `g6-nanode-1` nodes.
-
-The default configuration is 3 `g6-standard-2` relay nodes and 1 `g6-nanode-1` publisher node. So $80/month.
-
-**NOTE**: `moq-relay` does not scale particularly well right now.
-- The current design is a mesh network, so more nodes means more unnecessary backbone traffic.
-- Quinn currently uses a single UDP receive thread, so scaling to multiple cores won't help.
diff --git a/cdn/bootstrap.sh b/cdn/bootstrap.sh
deleted file mode 100644
index 3e6c4540e1..0000000000
--- a/cdn/bootstrap.sh
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/bin/bash
-#
-#
-
-set -euo pipefail
-
-echo "Starting bootstrap for moq-relay on Debian..."
-
-# Set hostname
-hostnamectl set-hostname "$HOSTNAME"
-
-# Install base packages
-echo "Installing base packages..."
-apt-get update
-apt-get install -y rsync curl
-
-# Create directories
-mkdir -p /var/lib/moq
-chmod 755 /var/lib/moq
-
-# Write GCP credentials
-echo "$GCP_ACCOUNT" | base64 -d > /var/lib/moq/gcp.json
-chmod 600 /var/lib/moq/gcp.json
-
-# Install Nix (multi-user installation)
-echo "Installing Nix package manager..."
-sh <(curl -L https://nixos.org/nix/install) --daemon --yes
-
-# Enable flakes and configure Cachix
-mkdir -p /etc/nix
-cat > /etc/nix/nix.conf <' to install services and credentials."
diff --git a/cdn/bootstrap.tf b/cdn/bootstrap.tf
deleted file mode 100644
index 129d1cffaf..0000000000
--- a/cdn/bootstrap.tf
+++ /dev/null
@@ -1,19 +0,0 @@
-# Service account for relay instances
-resource "google_service_account" "relay" {
- account_id = "moq-relay"
- display_name = "MoQ Relay"
- description = "Service account for MoQ relay instances"
-}
-
-# Generate service account key
-resource "google_service_account_key" "relay" {
- service_account_id = google_service_account.relay.name
-}
-
-# Bootstrap script to install Nix on first boot
-resource "linode_stackscript" "bootstrap" {
- label = "moq-bootstrap"
- description = "Bootstrap Debian with Nix"
- script = file("${path.module}/bootstrap.sh")
- images = ["linode/debian12"]
-}
diff --git a/cdn/dns.tf b/cdn/dns.tf
deleted file mode 100644
index 506f4a7267..0000000000
--- a/cdn/dns.tf
+++ /dev/null
@@ -1,64 +0,0 @@
-# DNS zone for relay servers
-resource "google_dns_managed_zone" "relay" {
- name = "relay-cdn"
- dns_name = "${var.domain}."
- dnssec_config {
- state = "on"
- }
-}
-
-# Individual DNS records for each relay node (for direct access)
-resource "google_dns_record_set" "relay_node" {
- for_each = local.relays
-
- name = "${each.key}.${google_dns_managed_zone.relay.dns_name}"
- managed_zone = google_dns_managed_zone.relay.name
- type = "A"
- ttl = 300
- rrdatas = linode_instance.relay[each.key].ipv4
-}
-
-# Global Geo DNS, routing to the closest region
-resource "google_dns_record_set" "relay_global" {
- name = google_dns_managed_zone.relay.dns_name
- managed_zone = google_dns_managed_zone.relay.name
- type = "A"
- ttl = 300
-
- routing_policy {
- dynamic "geo" {
- for_each = local.relay_gcp_regions
-
- content {
- location = geo.value
- rrdatas = linode_instance.relay[geo.key].ipv4
- }
- }
- }
-}
-
-# Region mapping for GCP geo routing
-# GCP uses region codes like "us-east1", "us-west1", "europe-west3", "asia-southeast1"
-locals {
- relay_gcp_regions = {
- usc = "us-central1" # Dallas, TX -> closest GCP region
- euc = "europe-west3" # Frankfurt -> closest GCP region
- sea = "asia-southeast1" # Singapore -> closest GCP region
- }
-}
-
-# DNS record for publisher node
-resource "google_dns_record_set" "publisher" {
- name = "pub.${google_dns_managed_zone.relay.dns_name}"
- managed_zone = google_dns_managed_zone.relay.name
- type = "A"
- ttl = 300
- rrdatas = linode_instance.publisher.ipv4
-}
-
-# Grant DNS admin permissions to the service account
-resource "google_project_iam_member" "dns_admin" {
- project = var.gcp_project
- role = "roles/dns.admin"
- member = "serviceAccount:${google_service_account.relay.email}"
-}
diff --git a/cdn/flake.lock b/cdn/flake.lock
deleted file mode 100644
index 80e369392a..0000000000
--- a/cdn/flake.lock
+++ /dev/null
@@ -1,368 +0,0 @@
-{
- "nodes": {
- "cdn": {
- "inputs": {
- "flake-utils": [
- "moq",
- "flake-utils"
- ],
- "moq": "moq_2",
- "nixpkgs": [
- "moq",
- "nixpkgs"
- ]
- },
- "locked": {
- "path": "./cdn",
- "type": "path"
- },
- "original": {
- "path": "./cdn",
- "type": "path"
- },
- "parent": [
- "moq"
- ]
- },
- "crane": {
- "locked": {
- "lastModified": 1763938834,
- "narHash": "sha256-j8iB0Yr4zAvQLueCZ5abxfk6fnG/SJ5JnGUziETjwfg=",
- "owner": "ipetkov",
- "repo": "crane",
- "rev": "d9e753122e51cee64eb8d2dddfe11148f339f5a2",
- "type": "github"
- },
- "original": {
- "owner": "ipetkov",
- "repo": "crane",
- "type": "github"
- }
- },
- "crane_2": {
- "locked": {
- "lastModified": 1760924934,
- "narHash": "sha256-tuuqY5aU7cUkR71sO2TraVKK2boYrdW3gCSXUkF4i44=",
- "owner": "ipetkov",
- "repo": "crane",
- "rev": "c6b4d5308293d0d04fcfeee92705017537cad02f",
- "type": "github"
- },
- "original": {
- "owner": "ipetkov",
- "repo": "crane",
- "type": "github"
- }
- },
- "flake-utils": {
- "inputs": {
- "systems": "systems"
- },
- "locked": {
- "lastModified": 1731533236,
- "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
- "owner": "numtide",
- "repo": "flake-utils",
- "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
- "type": "github"
- },
- "original": {
- "owner": "numtide",
- "repo": "flake-utils",
- "type": "github"
- }
- },
- "flake-utils_2": {
- "inputs": {
- "systems": "systems_2"
- },
- "locked": {
- "lastModified": 1731533236,
- "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
- "owner": "numtide",
- "repo": "flake-utils",
- "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
- "type": "github"
- },
- "original": {
- "owner": "numtide",
- "repo": "flake-utils",
- "type": "github"
- }
- },
- "flake-utils_3": {
- "inputs": {
- "systems": "systems_3"
- },
- "locked": {
- "lastModified": 1731533236,
- "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
- "owner": "numtide",
- "repo": "flake-utils",
- "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
- "type": "github"
- },
- "original": {
- "owner": "numtide",
- "repo": "flake-utils",
- "type": "github"
- }
- },
- "js": {
- "inputs": {
- "flake-utils": "flake-utils_3",
- "nixpkgs": "nixpkgs_2"
- },
- "locked": {
- "path": "./js",
- "type": "path"
- },
- "original": {
- "path": "./js",
- "type": "path"
- },
- "parent": [
- "moq"
- ]
- },
- "moq": {
- "inputs": {
- "cdn": "cdn",
- "crane": "crane_2",
- "flake-utils": "flake-utils_2",
- "js": "js",
- "nixpkgs": "nixpkgs_3",
- "rs": "rs",
- "rust-overlay": "rust-overlay_2"
- },
- "locked": {
- "lastModified": 1765242341,
- "narHash": "sha256-xctkx17D5+xKyWsqrgwbO20/c3QwVT+ITglHpcKBAK4=",
- "owner": "kixelated",
- "repo": "moq",
- "rev": "252c160fe07f907433b0c55bc64cb19d663bdeb6",
- "type": "github"
- },
- "original": {
- "owner": "kixelated",
- "repo": "moq",
- "type": "github"
- }
- },
- "moq_2": {
- "inputs": {
- "crane": "crane",
- "flake-utils": "flake-utils",
- "nixpkgs": "nixpkgs",
- "rust-overlay": "rust-overlay"
- },
- "locked": {
- "dir": "rs",
- "lastModified": 1765236058,
- "narHash": "sha256-v+dXxh4ZsWrtoCUUoTG3nzzXnZ+V5Bqugu5fqaev+30=",
- "owner": "kixelated",
- "repo": "moq",
- "rev": "07be135ae654c9bf806b450941bf42e3e05704e1",
- "type": "github"
- },
- "original": {
- "dir": "rs",
- "owner": "kixelated",
- "repo": "moq",
- "type": "github"
- }
- },
- "nixpkgs": {
- "locked": {
- "lastModified": 1763966396,
- "narHash": "sha256-6eeL1YPcY1MV3DDStIDIdy/zZCDKgHdkCmsrLJFiZf0=",
- "owner": "NixOS",
- "repo": "nixpkgs",
- "rev": "5ae3b07d8d6527c42f17c876e404993199144b6a",
- "type": "github"
- },
- "original": {
- "owner": "NixOS",
- "ref": "nixos-unstable",
- "repo": "nixpkgs",
- "type": "github"
- }
- },
- "nixpkgs_2": {
- "locked": {
- "lastModified": 1748792178,
- "narHash": "sha256-BHmgfHlCJVNisJShVaEmfDIr/Ip58i/4oFGlD1iK6lk=",
- "owner": "NixOS",
- "repo": "nixpkgs",
- "rev": "5929de975bcf4c7c8d8b5ca65c8cd9ef9e44523e",
- "type": "github"
- },
- "original": {
- "owner": "NixOS",
- "ref": "nixpkgs-unstable",
- "repo": "nixpkgs",
- "type": "github"
- }
- },
- "nixpkgs_3": {
- "locked": {
- "lastModified": 1761880412,
- "narHash": "sha256-QoJjGd4NstnyOG4mm4KXF+weBzA2AH/7gn1Pmpfcb0A=",
- "owner": "NixOS",
- "repo": "nixpkgs",
- "rev": "a7fc11be66bdfb5cdde611ee5ce381c183da8386",
- "type": "github"
- },
- "original": {
- "owner": "NixOS",
- "ref": "nixpkgs-unstable",
- "repo": "nixpkgs",
- "type": "github"
- }
- },
- "nixpkgs_4": {
- "locked": {
- "lastModified": 1764950072,
- "narHash": "sha256-BmPWzogsG2GsXZtlT+MTcAWeDK5hkbGRZTeZNW42fwA=",
- "owner": "NixOS",
- "repo": "nixpkgs",
- "rev": "f61125a668a320878494449750330ca58b78c557",
- "type": "github"
- },
- "original": {
- "owner": "NixOS",
- "ref": "nixos-unstable",
- "repo": "nixpkgs",
- "type": "github"
- }
- },
- "root": {
- "inputs": {
- "moq": "moq",
- "nixpkgs": "nixpkgs_4"
- }
- },
- "rs": {
- "inputs": {
- "crane": [
- "moq",
- "crane"
- ],
- "flake-utils": [
- "moq",
- "flake-utils"
- ],
- "nixpkgs": [
- "moq",
- "nixpkgs"
- ],
- "rust-overlay": [
- "moq",
- "rust-overlay"
- ]
- },
- "locked": {
- "path": "./rs",
- "type": "path"
- },
- "original": {
- "path": "./rs",
- "type": "path"
- },
- "parent": [
- "moq"
- ]
- },
- "rust-overlay": {
- "inputs": {
- "nixpkgs": [
- "moq",
- "cdn",
- "moq",
- "nixpkgs"
- ]
- },
- "locked": {
- "lastModified": 1764124769,
- "narHash": "sha256-vcoOEy3i8AGJi3Y2C48hrf6CuL2h8W1gLe1gNt72Kxg=",
- "owner": "oxalica",
- "repo": "rust-overlay",
- "rev": "5da8c00313b4434f00aed6b4c94cd3b207bafdc5",
- "type": "github"
- },
- "original": {
- "owner": "oxalica",
- "repo": "rust-overlay",
- "type": "github"
- }
- },
- "rust-overlay_2": {
- "inputs": {
- "nixpkgs": [
- "moq",
- "nixpkgs"
- ]
- },
- "locked": {
- "lastModified": 1761964689,
- "narHash": "sha256-Zo3LQQDz+64EQ9zor/WmeNTFLoZkjmhp0UY3G0D3seE=",
- "owner": "oxalica",
- "repo": "rust-overlay",
- "rev": "63d22578600f70d293aede6bc737efef60ebd97f",
- "type": "github"
- },
- "original": {
- "owner": "oxalica",
- "repo": "rust-overlay",
- "type": "github"
- }
- },
- "systems": {
- "locked": {
- "lastModified": 1681028828,
- "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
- "owner": "nix-systems",
- "repo": "default",
- "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
- "type": "github"
- },
- "original": {
- "owner": "nix-systems",
- "repo": "default",
- "type": "github"
- }
- },
- "systems_2": {
- "locked": {
- "lastModified": 1681028828,
- "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
- "owner": "nix-systems",
- "repo": "default",
- "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
- "type": "github"
- },
- "original": {
- "owner": "nix-systems",
- "repo": "default",
- "type": "github"
- }
- },
- "systems_3": {
- "locked": {
- "lastModified": 1681028828,
- "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
- "owner": "nix-systems",
- "repo": "default",
- "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
- "type": "github"
- },
- "original": {
- "owner": "nix-systems",
- "repo": "default",
- "type": "github"
- }
- }
- },
- "root": "root",
- "version": 7
-}
diff --git a/cdn/flake.nix b/cdn/flake.nix
deleted file mode 100644
index 6fa54845b8..0000000000
--- a/cdn/flake.nix
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- description = "MoQ relay server dependencies";
-
- inputs = {
- nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
- moq = {
- # Unfortunately, we can't use a relative path here because it executes on the remote.
- # TODO cross-compile locally and upload the binary to the remote.
- url = "github:moq-dev/moq";
- };
- };
-
- outputs =
- {
- nixpkgs,
- moq,
- ...
- }:
- {
- # Linux-only packages for deployment
- packages.x86_64-linux =
- let
- system = "x86_64-linux";
- pkgs = nixpkgs.legacyPackages.${system};
- in
- {
- default = pkgs.certbot.withPlugins (ps: [ ps.certbot-dns-google ]);
- certbot = pkgs.certbot.withPlugins (ps: [ ps.certbot-dns-google ]);
- moq-relay = moq.packages.${system}.moq-relay;
- cachix = pkgs.cachix;
- ffmpeg = pkgs.ffmpeg;
- hang-cli = moq.packages.${system}.hang;
- };
- };
-}
diff --git a/cdn/input.tf b/cdn/input.tf
deleted file mode 100644
index 6a5a40f582..0000000000
--- a/cdn/input.tf
+++ /dev/null
@@ -1,45 +0,0 @@
-variable "linode_token" {
- description = "Linode API token"
- type = string
- sensitive = true
-}
-
-variable "gcp_project" {
- description = "GCP project ID for DNS management"
- type = string
-}
-
-variable "domain" {
- description = "Relay domain name"
- type = string
-}
-
-variable "email" {
- description = "Email address for LetsEncrypt notifications"
- type = string
-}
-
-variable "ssh_keys" {
- description = "SSH public keys for root access"
- type = list(string)
-}
-
-# Relay node definitions
-# regions: https://api.linode.com/v4/regions
-# instance types: https://api.linode.com/v4/linode/types
-locals {
- relays = {
- usc = {
- region = "us-central" # Dallas, TX
- type = "g6-standard-2" # 4GB RAM, 2 vCPU, $24/mo, 4TB out
- }
- euc = {
- region = "eu-central" # Frankfurt, Germany
- type = "g6-standard-2"
- }
- sea = {
- region = "ap-south" # Singapore
- type = "g6-standard-2"
- }
- }
-}
diff --git a/cdn/justfile b/cdn/justfile
deleted file mode 100644
index 81c38c73d8..0000000000
--- a/cdn/justfile
+++ /dev/null
@@ -1,126 +0,0 @@
-# List all of the available commands.
-default:
- just --list
-
-# Get the hostname for a node
-host node:
- #!/usr/bin/env bash
- DOMAIN=$(tofu output -raw domain)
- echo "{{node}}.$DOMAIN"
-
-# Deploy moq-relay and moq-cert to all nodes
-[parallel]
-deploy-all: (deploy "usc") (deploy "euc") (deploy "sea")
-
-# List the available nodes.
-nodes:
- @echo "usc: Dallas, TX"
- @echo "euc: Frankfurt, Germany"
- @echo "sea: Singapore"
-
-# Deploy moq-relay and moq-cert to a specific node
-deploy node:
- #!/usr/bin/env bash
- set -euo pipefail
- HOST=$(just host {{node}})
- echo "Deploying to $HOST..."
-
- echo " Copying flake files to /var/lib/moq"
- rsync -az flake.nix flake.lock root@$HOST:/var/lib/moq/
-
- echo " Copying secrets to /var/lib/moq"
- rsync -az secrets/ root@$HOST:/var/lib/moq/
- ssh root@$HOST "chmod -R 755 /var/lib/moq && chmod 600 /var/lib/moq/*.jwk /var/lib/moq/*.jwt"
-
- echo " Copying systemd units to /etc/systemd/system/..."
- rsync -az relay/ root@$HOST:/etc/systemd/system/
-
- echo " Building and caching packages..."
- ssh root@$HOST "cd /var/lib/moq && nix build .#moq-relay -o relay && nix build .#certbot -o cert"
-
- echo " Enabling and starting services..."
- ssh root@$HOST "\
- systemctl daemon-reload && \
- systemctl enable moq-cert.service && \
- systemctl enable certbot-renew.timer && \
- systemctl enable vacuum.timer && \
- systemctl enable moq-relay.service && \
- systemctl start certbot-renew.timer && \
- systemctl start vacuum.timer && \
- systemctl start moq-cert.service && \
- systemctl restart moq-relay.service"
-
-# Check status of a node
-status node:
- #!/usr/bin/env bash
- HOST=$(just host {{node}})
- echo "=== $HOST ==="
- ssh root@$HOST "systemctl status moq-relay --no-pager"
-
-status-all: (status "usc") (status "euc") (status "sea")
-
-ssh node:
- #!/usr/bin/env bash
- ssh root@$(just host {{node}})
-
-# View logs from a specific node
-logs node:
- #!/usr/bin/env bash
- ssh root@$(just host {{node}}) "journalctl -u moq-relay -f"
-
-# Deploy publisher
-deploy-pub:
- #!/usr/bin/env bash
- set -euo pipefail
- HOST=$(just host pub)
- echo "Deploying to $HOST..."
-
- echo " Copying flake files to /var/lib/moq"
- rsync -az flake.nix flake.lock root@$HOST:/var/lib/moq/
-
- echo " Copying secrets to /var/lib/moq"
- rsync -az secrets/ root@$HOST:/var/lib/moq/
- ssh root@$HOST "chmod -R 755 /var/lib/moq && chmod 600 /var/lib/moq/*.jwt"
-
- echo " Copying systemd units to /etc/systemd/system/..."
- rsync -az pub/ root@$HOST:/etc/systemd/system/
-
- echo " Building and caching packages..."
- ssh root@$HOST "cd /var/lib/moq && nix build .#ffmpeg -o result && nix build .#hang-cli -o result-hang"
-
- echo " Enabling and starting services..."
- ssh root@$HOST "\
- systemctl daemon-reload && \
- systemctl enable hang-bbb-prepare.service && \
- systemctl enable hang-bbb.service && \
- systemctl enable vacuum.timer && \
- systemctl start hang-bbb-prepare.service && \
- systemctl start vacuum.timer && \
- systemctl restart hang-bbb.service"
-
-# Check status of publisher
-status-pub:
- #!/usr/bin/env bash
- HOST=$(just host pub)
- echo "=== $HOST ==="
- ssh root@$HOST "systemctl status hang-bbb --no-pager"
-
-# SSH into publisher
-ssh-pub:
- #!/usr/bin/env bash
- HOST=$(just host pub)
- ssh root@$HOST
-
-# View logs from publisher
-logs-pub:
- #!/usr/bin/env bash
- HOST=$(just host pub)
- ssh root@$HOST "journalctl -u hang-bbb -f"
-
-# Check tofu formatting
-check:
- tofu fmt -check -recursive
-
-# Fix tofu formatting
-fix:
- tofu fmt -recursive
diff --git a/cdn/main.tf b/cdn/main.tf
deleted file mode 100644
index 612fb2c311..0000000000
--- a/cdn/main.tf
+++ /dev/null
@@ -1,42 +0,0 @@
-terraform {
- required_providers {
- linode = {
- source = "linode/linode"
- version = "~> 3.4"
- }
-
- google = {
- source = "hashicorp/google"
- version = "~> 5.0"
- }
- }
-
- backend "local" {
- path = "tofu.tfstate"
- }
-
- required_version = ">= 1.6"
-}
-
-provider "linode" {
- token = var.linode_token
-}
-
-provider "google" {
- project = var.gcp_project
-}
-
-variable "gcp_service_list" {
- description = "The list of apis necessary for the project"
- type = list(string)
- default = [
- "dns.googleapis.com",
- ]
-}
-
-resource "google_project_service" "all" {
- for_each = toset(var.gcp_service_list)
- service = each.key
- disable_dependent_services = false
- disable_on_destroy = false
-}
diff --git a/cdn/output.tf b/cdn/output.tf
deleted file mode 100644
index 35f9de7079..0000000000
--- a/cdn/output.tf
+++ /dev/null
@@ -1,5 +0,0 @@
-# Domain configuration
-output "domain" {
- description = "Base domain for all nodes"
- value = var.domain
-}
diff --git a/cdn/pub/hang-bbb-prepare.service b/cdn/pub/hang-bbb-prepare.service
deleted file mode 100644
index a7bed17594..0000000000
--- a/cdn/pub/hang-bbb-prepare.service
+++ /dev/null
@@ -1,23 +0,0 @@
-[Unit]
-Description=Prepare video for hang-bbb publisher
-Before=hang-bbb.service
-
-[Service]
-Type=oneshot
-RemainAfterExit=yes
-User=root
-WorkingDirectory=/var/lib/moq
-
-ExecStart=/bin/bash -c '\
- # Download the video \
- /nix/var/nix/profiles/default/bin/nix shell nixpkgs#wget -c wget -nv http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 -O /var/lib/moq/tmp.mp4 && \
- # Fragment the video using ffmpeg from flake \
- /var/lib/moq/result-bin/bin/ffmpeg \
- -y -loglevel error -i /var/lib/moq/tmp.mp4 \
- -c copy \
- -f mp4 -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \
- /var/lib/moq/fragmented.mp4 && \
- rm -f /var/lib/moq/tmp.mp4'
-
-[Install]
-WantedBy=multi-user.target
diff --git a/cdn/pub/hang-bbb.service b/cdn/pub/hang-bbb.service
deleted file mode 100644
index 922f8789b7..0000000000
--- a/cdn/pub/hang-bbb.service
+++ /dev/null
@@ -1,37 +0,0 @@
-[Unit]
-Description=MoQ Publisher - Big Buck Bunny
-After=network-online.target hang-bbb-prepare.service
-Wants=network-online.target
-Requires=hang-bbb-prepare.service
-
-[Service]
-Type=simple
-User=root
-WorkingDirectory=/var/lib/moq
-Environment="RUST_LOG=debug"
-
-# Stream fragmented video in a loop and pipe to hang publish
-ExecStart=/bin/bash -c '\
- /var/lib/moq/result-bin/bin/ffmpeg \
- -stream_loop -1 \
- -hide_banner \
- -v quiet \
- -re \
- -i /var/lib/moq/fragmented.mp4 \
- -c copy \
- -f mp4 \
- -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \
- - | \
- /var/lib/moq/result-hang/bin/hang publish \
- --url "https://cdn.moq.dev/demo?jwt=$(cat /var/lib/moq/demo-pub.jwt)" \
- --name bbb'
-
-# Restart with exponential backoff
-Restart=always
-RestartSec=10s
-
-StandardOutput=journal
-StandardError=journal
-
-[Install]
-WantedBy=multi-user.target
diff --git a/cdn/pub/vacuum.service b/cdn/pub/vacuum.service
deleted file mode 100644
index 4f8a493942..0000000000
--- a/cdn/pub/vacuum.service
+++ /dev/null
@@ -1,10 +0,0 @@
-[Unit]
-Description=Vacuum systemd journal logs
-Documentation=man:journalctl(1)
-
-[Service]
-Type=oneshot
-ExecStart=/usr/bin/journalctl --vacuum-time=7d --vacuum-size=500M
-
-StandardOutput=journal
-StandardError=journal
diff --git a/cdn/pub/vacuum.timer b/cdn/pub/vacuum.timer
deleted file mode 100644
index 8bb7ec9e75..0000000000
--- a/cdn/pub/vacuum.timer
+++ /dev/null
@@ -1,12 +0,0 @@
-[Unit]
-Description=Vacuum systemd journal logs daily
-After=multi-user.target
-
-[Timer]
-# Run daily at 2am
-OnCalendar=02:00
-# Run on boot if we missed a scheduled run
-Persistent=true
-
-[Install]
-WantedBy=timers.target
diff --git a/cdn/publisher.tf b/cdn/publisher.tf
deleted file mode 100644
index 76a6b3069d..0000000000
--- a/cdn/publisher.tf
+++ /dev/null
@@ -1,48 +0,0 @@
-# Publisher instance
-resource "linode_instance" "publisher" {
- label = "publisher-moq"
- region = "us-central" # Dallas, TX
- type = "g6-nanode-1"
-
- # Use Debian 12 as base, will be converted to NixOS via bootstrap
- image = "linode/debian12"
- root_pass = random_password.publisher_root.result
- authorized_keys = var.ssh_keys
-
- # Publisher only needs outbound, no special inbound
- firewall_id = linode_firewall.publisher.id
-
- # Bootstrap script - only installs Nix and creates directories
- stackscript_id = linode_stackscript.bootstrap.id
- stackscript_data = {
- hostname = "pub.${var.domain}"
- gcp_account = google_service_account_key.relay.private_key
- }
-
- tags = ["publisher", "moq"]
-}
-
-# Generate random root password for publisher
-resource "random_password" "publisher_root" {
- length = 32
- special = true
-}
-
-# Firewall rules for publisher (SSH only)
-resource "linode_firewall" "publisher" {
- label = "publisher-firewall"
-
- inbound {
- label = "allow-ssh"
- action = "ACCEPT"
- protocol = "TCP"
- ports = "22"
- ipv4 = ["0.0.0.0/0"]
- ipv6 = ["::/0"]
- }
-
- inbound_policy = "DROP"
- outbound_policy = "ACCEPT"
-
- tags = ["publisher"]
-}
diff --git a/cdn/relay.tf b/cdn/relay.tf
deleted file mode 100644
index d41b95542b..0000000000
--- a/cdn/relay.tf
+++ /dev/null
@@ -1,86 +0,0 @@
-# Generate systemd service files from templates
-resource "local_file" "moq_relay_service" {
- content = templatefile("${path.module}/relay/moq-relay.service.tftpl", {
- domain = var.domain
- })
- filename = "${path.module}/relay/moq-relay.service"
-}
-
-resource "local_file" "moq_cert_service" {
- content = templatefile("${path.module}/relay/moq-cert.service.tftpl", {
- domain = var.domain
- email = var.email
- })
- filename = "${path.module}/relay/moq-cert.service"
-}
-
-# Create Linode instances
-resource "linode_instance" "relay" {
- for_each = local.relays
-
- label = "relay-${each.key}"
- region = each.value.region
- type = each.value.type
-
- # Use Debian 12 as base, will be converted to NixOS via bootstrap
- image = "linode/debian12"
- root_pass = random_password.relay_root[each.key].result
- authorized_keys = var.ssh_keys
-
- # Open firewall for QUIC/WebTransport
- firewall_id = linode_firewall.relay.id
-
- # Bootstrap script - only installs Nix and creates directories
- stackscript_id = linode_stackscript.bootstrap.id
- stackscript_data = {
- hostname = "${each.key}.${var.domain}"
- gcp_account = google_service_account_key.relay.private_key
- }
-
- tags = ["relay", "moq"]
-}
-
-# Generate random root passwords (store these securely!)
-resource "random_password" "relay_root" {
- for_each = local.relays
-
- length = 32
- special = true
-}
-
-# Firewall rules for relay servers
-resource "linode_firewall" "relay" {
- label = "relay-firewall"
-
- inbound {
- label = "allow-ssh"
- action = "ACCEPT"
- protocol = "TCP"
- ports = "22"
- ipv4 = ["0.0.0.0/0"]
- ipv6 = ["::/0"]
- }
-
- inbound {
- label = "allow-quic-udp"
- action = "ACCEPT"
- protocol = "UDP"
- ports = "443"
- ipv4 = ["0.0.0.0/0"]
- ipv6 = ["::/0"]
- }
-
- inbound {
- label = "allow-quic-tcp"
- action = "ACCEPT"
- protocol = "TCP"
- ports = "443"
- ipv4 = ["0.0.0.0/0"]
- ipv6 = ["::/0"]
- }
-
- inbound_policy = "DROP"
- outbound_policy = "ACCEPT"
-
- tags = ["relay"]
-}
diff --git a/cdn/relay/certbot-renew.service b/cdn/relay/certbot-renew.service
deleted file mode 100644
index cdf30e44cd..0000000000
--- a/cdn/relay/certbot-renew.service
+++ /dev/null
@@ -1,12 +0,0 @@
-[Unit]
-Description=Certbot Certificate Renewal
-After=network-online.target
-Wants=network-online.target
-
-[Service]
-Type=oneshot
-# Renew certificates if they're close to expiration
-ExecStart=/var/lib/moq/cert/bin/certbot renew
-
-StandardOutput=journal
-StandardError=journal
diff --git a/cdn/relay/certbot-renew.timer b/cdn/relay/certbot-renew.timer
deleted file mode 100644
index aa1b7856dc..0000000000
--- a/cdn/relay/certbot-renew.timer
+++ /dev/null
@@ -1,14 +0,0 @@
-[Unit]
-Description=Certbot Certificate Renewal Timer
-After=network-online.target
-
-[Timer]
-# Run daily at 3am
-OnCalendar=03:00
-# Run on boot if we missed a scheduled run
-Persistent=true
-# Add some randomness to avoid all nodes hitting Let's Encrypt at once
-RandomizedDelaySec=3600
-
-[Install]
-WantedBy=timers.target
diff --git a/cdn/relay/moq-cert.service b/cdn/relay/moq-cert.service
deleted file mode 100644
index b634b070f1..0000000000
--- a/cdn/relay/moq-cert.service
+++ /dev/null
@@ -1,23 +0,0 @@
-[Unit]
-Description=MoQ Initial Certificate
-After=network-online.target
-Wants=network-online.target
-
-[Service]
-Type=oneshot
-RemainAfterExit=yes
-# Obtain initial certificate
-ExecStart=/bin/sh -c '/var/lib/moq/cert/bin/certbot certonly \
- --dns-google \
- --dns-google-credentials /var/lib/moq/gcp.json \
- --non-interactive \
- --agree-tos \
- --email admin@moq.dev \
- -d cdn.moq.dev \
- -d $(hostname)'
-
-StandardOutput=journal
-StandardError=journal
-
-[Install]
-WantedBy=multi-user.target
diff --git a/cdn/relay/moq-cert.service.tftpl b/cdn/relay/moq-cert.service.tftpl
deleted file mode 100644
index 86ab44d49a..0000000000
--- a/cdn/relay/moq-cert.service.tftpl
+++ /dev/null
@@ -1,23 +0,0 @@
-[Unit]
-Description=MoQ Initial Certificate
-After=network-online.target
-Wants=network-online.target
-
-[Service]
-Type=oneshot
-RemainAfterExit=yes
-# Obtain initial certificate
-ExecStart=/bin/sh -c '/var/lib/moq/cert/bin/certbot certonly \
- --dns-google \
- --dns-google-credentials /var/lib/moq/gcp.json \
- --non-interactive \
- --agree-tos \
- --email ${email} \
- -d ${domain} \
- -d $(hostname)'
-
-StandardOutput=journal
-StandardError=journal
-
-[Install]
-WantedBy=multi-user.target
diff --git a/cdn/relay/moq-relay.service b/cdn/relay/moq-relay.service
deleted file mode 100644
index 9ae964eca7..0000000000
--- a/cdn/relay/moq-relay.service
+++ /dev/null
@@ -1,37 +0,0 @@
-[Unit]
-Description=MoQ Relay Server
-After=network-online.target moq-cert.service
-Wants=network-online.target
-Requires=moq-cert.service
-
-[Service]
-Type=notify
-User=root
-WorkingDirectory=/var/lib/moq
-Environment="RUST_LOG=trace"
-Environment="RUST_BACKTRACE=1"
-
-ExecStart=/var/lib/moq/relay/bin/moq-relay \
- --listen [::]:443 \
- --tls-cert /etc/letsencrypt/live/cdn.moq.dev/fullchain.pem \
- --tls-key /etc/letsencrypt/live/cdn.moq.dev/privkey.pem \
- --auth-key /var/lib/moq/root.jwk \
- --auth-public anon \
- --web-https-listen [::]:443 \
- --web-https-cert /etc/letsencrypt/live/cdn.moq.dev/fullchain.pem \
- --web-https-key /etc/letsencrypt/live/cdn.moq.dev/privkey.pem \
- --cluster-root usc.cdn.moq.dev \
- --cluster-node %H \
- --cluster-token /var/lib/moq/cluster.jwt
-
-Restart=always
-RestartSec=10
-StandardOutput=journal
-StandardError=journal
-
-# Security hardening - relaxed to allow Nix to work
-NoNewPrivileges=true
-AmbientCapabilities=CAP_NET_BIND_SERVICE
-
-[Install]
-WantedBy=multi-user.target
diff --git a/cdn/relay/moq-relay.service.tftpl b/cdn/relay/moq-relay.service.tftpl
deleted file mode 100644
index 0a62715f6b..0000000000
--- a/cdn/relay/moq-relay.service.tftpl
+++ /dev/null
@@ -1,37 +0,0 @@
-[Unit]
-Description=MoQ Relay Server
-After=network-online.target moq-cert.service
-Wants=network-online.target
-Requires=moq-cert.service
-
-[Service]
-Type=notify
-User=root
-WorkingDirectory=/var/lib/moq
-Environment="RUST_LOG=trace"
-Environment="RUST_BACKTRACE=1"
-
-ExecStart=/var/lib/moq/relay/bin/moq-relay \
- --listen [::]:443 \
- --tls-cert /etc/letsencrypt/live/${domain}/fullchain.pem \
- --tls-key /etc/letsencrypt/live/${domain}/privkey.pem \
- --auth-key /var/lib/moq/root.jwk \
- --auth-public anon \
- --web-https-listen [::]:443 \
- --web-https-cert /etc/letsencrypt/live/${domain}/fullchain.pem \
- --web-https-key /etc/letsencrypt/live/${domain}/privkey.pem \
- --cluster-root usc.${domain} \
- --cluster-node %H \
- --cluster-token /var/lib/moq/cluster.jwt
-
-Restart=always
-RestartSec=10
-StandardOutput=journal
-StandardError=journal
-
-# Security hardening - relaxed to allow Nix to work
-NoNewPrivileges=true
-AmbientCapabilities=CAP_NET_BIND_SERVICE
-
-[Install]
-WantedBy=multi-user.target
diff --git a/cdn/relay/vacuum.service b/cdn/relay/vacuum.service
deleted file mode 100644
index 81f19ac24c..0000000000
--- a/cdn/relay/vacuum.service
+++ /dev/null
@@ -1,11 +0,0 @@
-[Unit]
-Description=Vacuum systemd journal logs and Nix store
-Documentation=man:journalctl(1) man:nix-collect-garbage(1)
-
-[Service]
-Type=oneshot
-ExecStart=/usr/bin/journalctl --vacuum-time=7d --vacuum-size=500M
-ExecStart=/usr/bin/nix-collect-garbage --delete-older-than 7d
-
-StandardOutput=journal
-StandardError=journal
diff --git a/cdn/relay/vacuum.timer b/cdn/relay/vacuum.timer
deleted file mode 100644
index 8bb7ec9e75..0000000000
--- a/cdn/relay/vacuum.timer
+++ /dev/null
@@ -1,12 +0,0 @@
-[Unit]
-Description=Vacuum systemd journal logs daily
-After=multi-user.target
-
-[Timer]
-# Run daily at 2am
-OnCalendar=02:00
-# Run on boot if we missed a scheduled run
-Persistent=true
-
-[Install]
-WantedBy=timers.target
diff --git a/cdn/terraform.tfvars.example b/cdn/terraform.tfvars.example
deleted file mode 100644
index 2a8c239d5b..0000000000
--- a/cdn/terraform.tfvars.example
+++ /dev/null
@@ -1,20 +0,0 @@
-# Copy this file to terraform.tfvars and fill in your values
-
-# The domain to use for the GeoDNS entry.
-# Each node will register its own subdomain.
-domain = "cdn.moq.dev"
-
-# Linode API token (create at: https://cloud.linode.com/profile/tokens)
-linode_token = "your-linode-api-token"
-
-# GCP project ID for DNS management
-# Note: GCP credentials will be read from ~/.config/gcloud or GOOGLE_APPLICATION_CREDENTIALS
-gcp_project = "your-gcp-project-id"
-
-# Email for LetsEncrypt notifications
-email = "admin@moq.dev"
-
-# Your SSH public key for root access
-ssh_keys = [
- "ssh-ed25519 AAAA... your-key-comment",
-]
diff --git a/demo/boy/.gitignore b/demo/boy/.gitignore
new file mode 100644
index 0000000000..2a82664306
--- /dev/null
+++ b/demo/boy/.gitignore
@@ -0,0 +1,2 @@
+rom/
+.wrangler/
diff --git a/demo/boy/CHANGELOG.md b/demo/boy/CHANGELOG.md
new file mode 100644
index 0000000000..2e9d8af00f
--- /dev/null
+++ b/demo/boy/CHANGELOG.md
@@ -0,0 +1,14 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+## [0.1.0](https://github.com/moq-dev/moq/releases/tag/moq-boy-v0.1.0) - 2026-04-03
+
+### Other
+
+- Replace drone demo with MoQ Boy ([#1197](https://github.com/moq-dev/moq/pull/1197))
diff --git a/demo/boy/justfile b/demo/boy/justfile
new file mode 100644
index 0000000000..b5387236ae
--- /dev/null
+++ b/demo/boy/justfile
@@ -0,0 +1,86 @@
+set fallback
+
+# Run the GB demo: relay + emulator publisher + web viewer.
+default:
+ bun install
+
+ bun run concurrently --kill-others --prefix-colors auto \
+ "just relay" \
+ "just wait && just b2s" \
+ "just wait && just dangan" \
+ "just wait && just web"
+
+# Download the given ROM from our R2 bucket.
+download rom:
+ #!/usr/bin/env bash
+ if [ ! -f "rom/{{ rom }}" ]; then
+ mkdir -p rom
+ curl -fSL -o "rom/{{ rom }}.tmp" "https://rom.moq.dev/{{ rom }}"
+ mv "rom/{{ rom }}.tmp" "rom/{{ rom }}"
+ fi
+
+# Big2Small (puzzle): https://mdsteele.itch.io/big2small
+b2s:
+ just download big2small.gb
+ just start rom/big2small.gb
+
+# Dangan GB2 (bullet hell): https://snorpung.itch.io/dangan-gb2
+dangan:
+ just download DanganGB2.gbc
+ just start rom/DanganGB2.gbc
+
+# Opossum Country (horror): https://benjelter.itch.io/opossum-country
+opossum:
+ just download OpossumCountry.gbc
+ just start rom/OpossumCountry.gbc
+
+# Swordbird Song - Iron Owl Tower
+songbird:
+ just download Swordbird.gb
+ just start rom/Swordbird.gb
+
+# RunieStory
+runiestory:
+ just download RunieStory.gb
+ just start rom/RunieStory.gb
+
+# GB Run (racing): https://biscuitlocker.itch.io/gb-run
+gb-run:
+ just download gb-run.gbc
+ just start rom/gb-run.gbc
+
+# Start a Game Boy emulator publisher.
+start rom url='http://localhost:4443/anon':
+ cargo run --bin moq-boy -- --url "{{ url }}" --rom "{{ rom }}" --location localhost
+
+# Host the web server
+web url='http://localhost:4443/anon':
+ VITE_RELAY_URL="{{ url }}" bun --bun vite --open
+
+# --- rom.moq.dev (R2 hosting) ---
+
+# Deploy the rom.moq.dev worker
+deploy:
+ bun install
+ bun wrangler deploy
+
+# Upload a single ROM to R2
+upload file:
+ bun install
+ bun wrangler r2 object put "rom-moq-dev/{{ file }}" --file "rom/{{ file }}" --remote
+
+# Sync all ROMs to R2
+sync dir="rom":
+ #!/usr/bin/env bash
+ set -euo pipefail
+ for file in "{{ dir }}"/*; do
+ [ -e "$file" ] || continue
+ key=$(basename "$file")
+ echo "Uploading $key..."
+ bun wrangler r2 object put "rom-moq-dev/$key" --file "$file" --remote
+ done
+
+# List files in the R2 bucket
+list:
+ bun install
+ bun wrangler r2 object list "rom-moq-dev"
diff --git a/demo/boy/package.json b/demo/boy/package.json
new file mode 100644
index 0000000000..029c40fe7c
--- /dev/null
+++ b/demo/boy/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@moq/demo-boy",
+ "private": true,
+ "type": "module",
+ "version": "0.2.0",
+ "description": "MoQ Boy Demo - Crowd-controlled Game Boy streaming",
+ "license": "(MIT OR Apache-2.0)",
+ "scripts": {
+ "dev": "vite --open"
+ },
+ "dependencies": {
+ "@moq/boy": "workspace:^"
+ },
+ "devDependencies": {
+ "esbuild": "^0.27.0",
+ "typescript": "^6.0.0",
+ "vite": "^7.3.1",
+ "vite-plugin-solid": "^2.11.10"
+ }
+}
diff --git a/demo/boy/src/index.html b/demo/boy/src/index.html
new file mode 100644
index 0000000000..b78d0461f0
--- /dev/null
+++ b/demo/boy/src/index.html
@@ -0,0 +1,81 @@
+
+
+
+
+
+ MoQ Boy
+
+
+
+
+
+
+
+
+
Click a game to play. Multiple players can control it. (anarchy!)
+
+ A generic MoQ relay is used for everything:
+
+
+ Discovering online games and players.
+ Transmitting audio/video tracks, metadata, and player controls.
+ Pausing emulation/encoding when there are no subscribers.
+
+
+
+
+
+
+
+
diff --git a/demo/boy/src/index.ts b/demo/boy/src/index.ts
new file mode 100644
index 0000000000..6258d1d7b7
--- /dev/null
+++ b/demo/boy/src/index.ts
@@ -0,0 +1,15 @@
+import "@moq/boy/element";
+import { Effect } from "@moq/signals";
+
+const url = import.meta.env.VITE_RELAY_URL || "http://localhost:4443/anon";
+
+const boy = document.querySelector("moq-boy");
+if (boy) boy.url = url;
+
+const about = document.getElementById("about");
+if (boy && about) {
+ const effect = new Effect();
+ effect.run((inner) => {
+ about.hidden = inner.get(boy.expanded) !== undefined;
+ });
+}
diff --git a/demo/boy/src/worker.ts b/demo/boy/src/worker.ts
new file mode 100644
index 0000000000..3f7db4ec4b
--- /dev/null
+++ b/demo/boy/src/worker.ts
@@ -0,0 +1,46 @@
+// Cloudflare Worker for rom.moq.dev — serves Game Boy ROMs from R2.
+
+interface Env {
+ ROM: R2Bucket;
+}
+
+const CONTENT_TYPES: Record = {
+ ".gb": "application/octet-stream",
+ ".gbc": "application/octet-stream",
+};
+
+export default {
+ async fetch(request: Request, env: Env): Promise {
+ if (request.method !== "GET" && request.method !== "HEAD") {
+ return new Response("Method Not Allowed", { status: 405 });
+ }
+
+ const url = new URL(request.url);
+ const key = url.pathname.slice(1);
+
+ if (!key) {
+ return new Response("Not Found", { status: 404 });
+ }
+
+ const object = await env.ROM.get(key);
+ if (!object) {
+ return new Response("Not Found", { status: 404 });
+ }
+
+ const dotIdx = key.lastIndexOf(".");
+ const ext = dotIdx >= 0 ? key.substring(dotIdx).toLowerCase() : "";
+ const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
+
+ const headers = {
+ "Content-Type": contentType,
+ "Cache-Control": "public, max-age=2592000",
+ "Content-Length": object.size.toString(),
+ };
+
+ if (request.method === "HEAD") {
+ return new Response(null, { headers });
+ }
+
+ return new Response(object.body, { headers });
+ },
+};
diff --git a/demo/boy/vite.config.ts b/demo/boy/vite.config.ts
new file mode 100644
index 0000000000..8b4b4d490a
--- /dev/null
+++ b/demo/boy/vite.config.ts
@@ -0,0 +1,24 @@
+import { resolve } from "path";
+import { defineConfig } from "vite";
+import solidPlugin from "vite-plugin-solid";
+import { workletInline } from "../../js/common/vite-plugin-worklet";
+
+export default defineConfig({
+ root: "src",
+ envDir: resolve(__dirname),
+ plugins: [solidPlugin(), workletInline()],
+ build: {
+ target: "esnext",
+ rollupOptions: {
+ input: {
+ main: resolve(__dirname, "src/index.html"),
+ },
+ },
+ },
+ server: {
+ hmr: false,
+ },
+ optimizeDeps: {
+ exclude: ["@libav.js/variant-opus-af"],
+ },
+});
diff --git a/demo/boy/wrangler.jsonc b/demo/boy/wrangler.jsonc
new file mode 100644
index 0000000000..1769469752
--- /dev/null
+++ b/demo/boy/wrangler.jsonc
@@ -0,0 +1,27 @@
+{
+ // Cloudflare Wrangler configuration for ROM hosting (rom.moq.dev)
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "rom-moq-dev",
+ "main": "src/worker.ts",
+ "compatibility_date": "2025-07-09",
+ "account_id": "dd618f5dbd5da77b8296f1613c301f5c",
+ "workers_dev": false,
+ "route": {
+ "pattern": "rom.moq.dev",
+ "custom_domain": true
+ },
+ // R2 bucket bindings
+ "r2_buckets": [
+ {
+ "bucket_name": "rom-moq-dev",
+ "binding": "ROM",
+ "remote": true
+ }
+ ],
+ // Observability
+ "observability": {
+ "logs": {
+ "enabled": true
+ }
+ }
+}
diff --git a/demo/justfile b/demo/justfile
new file mode 100644
index 0000000000..c94cd57e20
--- /dev/null
+++ b/demo/justfile
@@ -0,0 +1,30 @@
+set fallback
+
+mod boy
+mod pub
+mod relay
+mod sub
+mod web
+
+# Run the web demo (default).
+default:
+ bun install
+ bun run concurrently --kill-others --names srv,bbb,web --prefix-colors auto \
+ "just relay" \
+ "just wait && just pub bbb" \
+ "just wait && just web"
+
+# Wait for the localhost relay to be ready by repeatedly requesting the cert
+[private]
+wait url="http://localhost:4443/certificate.sha256":
+ #!/usr/bin/env bash
+ set -euo pipefail
+ for i in $(seq 1 600); do
+ curl -sf "{{ url }}" > /dev/null 2>&1 && exit 0
+ sleep 1
+ done
+ exit 1
+
+# Throttle UDP traffic for testing (macOS only, requires sudo).
+throttle:
+ throttle/enable
diff --git a/demo/pub/bun.lock b/demo/pub/bun.lock
new file mode 100644
index 0000000000..2842e269c2
--- /dev/null
+++ b/demo/pub/bun.lock
@@ -0,0 +1,194 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "video-moq-dev",
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4",
+ "wrangler": "^4",
+ },
+ },
+ },
+ "packages": {
+ "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
+
+ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="],
+
+ "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260329.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-oyDXYlPBuGXKkZ85+M3jFz0/qYmvA4AEURN8USIGPDCR5q+HFSRwywSd9neTx3Wi7jhey2wuYaEpD3fEFWyWUA=="],
+
+ "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260329.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-++ZxVa3ovzYeDLEG6zMqql9gzZAG8vak6ZSBQgprGKZp7akr+GKTpw9f3RrMP552NSi3gTisroLobrrkPBtYLQ=="],
+
+ "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260329.1", "", { "os": "linux", "cpu": "x64" }, "sha512-kkeywAgIHwbqHkVILqbj/YkfbrA6ARbmutjiYzZA2MwMSfNXlw6/kedAKOY8YwcymZIgepx3YTIPnBP50pOotw=="],
+
+ "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260329.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-eYBN20+B7XOUSWEe0mlqkMUbfLoIKjKZnpqQiSxnLbL72JKY0D/KlfN/b7RVGLpewB7i8rTrwTNr0szCKnZzSQ=="],
+
+ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260329.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5R+/oxrDhS9nL3oA3ZWtD6ndMOqm7RfKknDNxLcmYW5DkUu7UH3J/s1t/Dz66iFePzr5BJmE7/8gbmve6TjtZQ=="],
+
+ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260401.1", "", {}, "sha512-tKBeV/ySfJjbO0qMKkFrstHDdWzZHAcW4vCpO5QaqjB/667y9lhZt9gZyTKeJ0gluIBwpeQ/efBjqRLqpkgw9g=="],
+
+ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
+
+ "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
+
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
+
+ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
+
+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
+
+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
+
+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
+
+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
+
+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
+
+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
+
+ "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
+
+ "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
+
+ "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
+
+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
+
+ "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
+
+ "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
+
+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
+
+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
+
+ "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
+
+ "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
+
+ "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
+
+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
+
+ "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
+
+ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
+
+ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
+
+ "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
+
+ "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
+
+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
+
+ "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
+
+ "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
+
+ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
+
+ "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
+
+ "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="],
+
+ "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
+
+ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
+
+ "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
+
+ "miniflare": ["miniflare@4.20260329.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260329.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-+G+1YFVeuEpw/gZZmUHQR7IfzJV+DDGvnSl0yXzhgvHh8Nbr8Go5uiWIwl17EyZ1Uors3FKUMDUyU6+ejeKZOw=="],
+
+ "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
+
+ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+
+ "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+
+ "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
+
+ "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
+
+ "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
+
+ "workerd": ["workerd@1.20260329.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260329.1", "@cloudflare/workerd-darwin-arm64": "1.20260329.1", "@cloudflare/workerd-linux-64": "1.20260329.1", "@cloudflare/workerd-linux-arm64": "1.20260329.1", "@cloudflare/workerd-windows-64": "1.20260329.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-+ifMv3uBuD33ee7pan5n8+sgVxm2u5HnbgfXzHKwMNTKw86znqBJSnJoBqtP88+2T5U2Lu11xXUt+khPYioXwQ=="],
+
+ "wrangler": ["wrangler@4.79.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260329.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260329.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260329.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-NMinIdB1pXIqdk+NLw4+RjzB7K5z4+lWMxhTxFTfZomwJu3Pm6N+kZ+a66D3nI7w0oCjsdv/umrZVmSHCBp2cg=="],
+
+ "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
+
+ "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
+
+ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
+ }
+}
diff --git a/demo/pub/justfile b/demo/pub/justfile
new file mode 100644
index 0000000000..ccf74cac0e
--- /dev/null
+++ b/demo/pub/justfile
@@ -0,0 +1,251 @@
+set fallback
+
+# --- R2 bucket management (vid.moq.dev) ---
+
+# Initialize the bucket (this is mine; make your own)
+init:
+ bun install
+ bun wrangler r2 bucket create video-moq-dev
+
+# Deploy the worker
+deploy:
+ bun install
+ bun wrangler deploy
+
+# Upload a single file to R2.
+# NOTE: Videos should be fragmented to minimize latency when streaming.
+# Use ffmpeg to fragment before uploading:
+# ffmpeg -i input.mp4 -c copy -f mp4 \
+# -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \
+# output.mp4
+upload file:
+ bun install
+ bun wrangler r2 object put "video-moq-dev/{{ file }}" --file "media/{{ file }}" --remote
+
+# Sync all files in a local directory to R2
+sync dir="media":
+ #!/usr/bin/env bash
+ set -euo pipefail
+ for file in "{{ dir }}"/*; do
+ [ -e "$file" ] || continue
+ key=$(basename "$file")
+ echo "Uploading $key..."
+ bun wrangler r2 object put "video-moq-dev/$key" --file "$file" --remote
+ done
+
+# List files in the bucket
+list:
+ bun install
+ bun wrangler r2 object list "video-moq-dev"
+
+# --- Publishing ---
+
+# Publish Big Buck Bunny to a relay server.
+bbb url='http://localhost:4443/anon' *args:
+ just download bbb
+ just cmaf bbb "{{ url }}" {{ args }}
+
+# Publish Tears of Steel to a relay server.
+tos url='http://localhost:4443/anon' *args:
+ just download tos
+ just cmaf tos "{{ url }}" {{ args }}
+
+# Publish a video using ffmpeg to a relay server.
+cmaf name url='http://localhost:4443/anon' *args:
+ cargo build --bin moq-cli
+ just ffmpeg-cmaf "media/{{ name }}.mp4" |\
+ cargo run --bin moq-cli -- \
+ {{ args }} publish --url "{{ url }}" --name "{{ name }}.hang" fmp4
+
+# Publish a video via iroh.
+iroh name url prefix="":
+ just download "{{ name }}"
+ cargo build --bin moq-cli
+ just ffmpeg-cmaf "media/{{ name }}.mp4" |\
+ cargo run --bin moq-cli -- \
+ --iroh-enabled publish --url "{{ url }}" --name "{{ prefix }}{{ name }}.hang" fmp4
+
+# Generate and ingest an HLS stream from a video file.
+hls name relay="http://localhost:4443/anon":
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ just download "{{ name }}"
+
+ INPUT="media/{{ name }}.mp4"
+ OUT_DIR="media/{{ name }}"
+
+ rm -rf "$OUT_DIR"
+ mkdir -p "$OUT_DIR"
+
+ echo ">>> Generating HLS stream to disk (1280x720 + 256x144)..."
+
+ ffmpeg -hide_banner -loglevel warning -re -stream_loop -1 -i "$INPUT" \
+ -filter_complex "\
+ [0:v]split=2[v0][v1]; \
+ [v0]scale=-2:720[v720]; \
+ [v1]scale=-2:144[v144]" \
+ -map "[v720]" -map "[v144]" -map 0:a:0 \
+ -r 25 -preset veryfast -g 50 -keyint_min 50 -sc_threshold 0 \
+ -c:v:0 libx264 -profile:v:0 high -level:v:0 4.1 -pix_fmt:v:0 yuv420p -tag:v:0 avc1 \
+ -b:v:0 4M -maxrate:v:0 4.4M -bufsize:v:0 8M \
+ -c:v:1 libx264 -profile:v:1 high -level:v:1 4.1 -pix_fmt:v:1 yuv420p -tag:v:1 avc1 \
+ -b:v:1 300k -maxrate:v:1 330k -bufsize:v:1 600k \
+ -c:a aac -b:a 128k \
+ -f hls -hls_time 2 -hls_list_size 6 \
+ -hls_flags independent_segments+delete_segments \
+ -hls_segment_type fmp4 \
+ -master_pl_name master.m3u8 \
+ -var_stream_map "v:0,agroup:audio,name:720 v:1,agroup:audio,name:144 a:0,agroup:audio,name:audio" \
+ -hls_segment_filename "$OUT_DIR/v%v/segment_%09d.m4s" \
+ "$OUT_DIR/v%v/stream.m3u8" &
+
+ FFMPEG_PID=$!
+
+ echo ">>> Waiting for HLS playlist generation..."
+ for i in {1..30}; do
+ if [ -f "$OUT_DIR/master.m3u8" ]; then break; fi
+ sleep 0.5
+ done
+
+ if [ ! -f "$OUT_DIR/master.m3u8" ]; then
+ kill $FFMPEG_PID 2>/dev/null || true
+ echo "Error: master.m3u8 not generated in time"
+ exit 1
+ fi
+
+ echo ">>> Waiting for variant playlists..."
+ sleep 2
+ for i in {1..20}; do
+ if [ -f "$OUT_DIR/v0/stream.m3u8" ] || [ -f "$OUT_DIR/v720/stream.m3u8" ] || [ -f "$OUT_DIR/v144/stream.m3u8" ] || [ -f "$OUT_DIR/vaudio/stream.m3u8" ]; then
+ break
+ fi
+ sleep 0.5
+ done
+
+ CLEANUP_CALLED=false
+ cleanup() {
+ if [ "$CLEANUP_CALLED" = "true" ]; then return; fi
+ CLEANUP_CALLED=true
+ echo "Shutting down..."
+ kill $FFMPEG_PID 2>/dev/null || true
+ sleep 0.5
+ kill -9 $FFMPEG_PID 2>/dev/null || true
+ }
+ trap cleanup SIGINT SIGTERM EXIT
+
+ echo ">>> Running with --passthrough flag"
+ cargo run --bin moq-cli -- publish --url "{{ relay }}" --name "{{ name }}.hang" hls --playlist "$OUT_DIR/master.m3u8" --passthrough
+ EXIT_CODE=$?
+
+ cleanup
+ exit $EXIT_CODE
+
+# Publish a video using H.264 Annex B format.
+h264 name url="http://localhost:4443/anon" *args:
+ just download "{{ name }}"
+ cargo build --bin moq-cli
+
+ ffmpeg -hide_banner -v quiet \
+ -stream_loop -1 -re \
+ -i "media/{{ name }}.mp4" \
+ -c:v copy -an \
+ -bsf:v h264_mp4toannexb \
+ -f h264 \
+ - | cargo run --bin moq-cli -- publish --url "{{ url }}" --name "{{ name }}.hang" avc3 {{ args }}
+
+# Publish a video using ffmpeg directly from moq to the localhost.
+serve name *args:
+ just download "{{ name }}"
+ cargo build --bin moq-cli
+ just ffmpeg-cmaf "media/{{ name }}.mp4" |\
+ cargo run --bin moq-cli -- \
+ {{ args }} serve --listen "[::]:4443" --tls-generate "localhost" \
+ --name "{{ name }}.hang" fmp4
+
+# Generate and serve an HLS stream from a video for testing.
+serve-hls name port="8000":
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ just download "{{ name }}"
+
+ INPUT="media/{{ name }}.mp4"
+ OUT_DIR="media/{{ name }}"
+
+ rm -rf "$OUT_DIR"
+ mkdir -p "$OUT_DIR"
+
+ echo ">>> Starting HLS stream generation..."
+ echo ">>> Master playlist: http://localhost:{{ port }}/master.m3u8"
+
+ cleanup() {
+ echo "Shutting down..."
+ kill $(jobs -p) 2>/dev/null || true
+ exit 0
+ }
+ trap cleanup SIGINT SIGTERM
+
+ ffmpeg -loglevel warning -re -stream_loop -1 -i "$INPUT" \
+ -map 0:v:0 -map 0:v:0 -map 0:a:0 \
+ -r 25 -preset veryfast -g 50 -keyint_min 50 -sc_threshold 0 \
+ -c:v:0 libx264 -profile:v:0 high -level:v:0 4.1 -pix_fmt:v:0 yuv420p -tag:v:0 avc1 -bsf:v:0 dump_extra -b:v:0 4M -vf:0 "scale=1920:-2" \
+ -c:v:1 libx264 -profile:v:1 high -level:v:1 4.1 -pix_fmt:v:1 yuv420p -tag:v:1 avc1 -bsf:v:1 dump_extra -b:v:1 300k -vf:1 "scale=256:-2" \
+ -c:a aac -b:a 128k \
+ -f hls \
+ -hls_time 2 -hls_list_size 12 \
+ -hls_flags independent_segments+delete_segments \
+ -hls_segment_type fmp4 \
+ -master_pl_name master.m3u8 \
+ -var_stream_map "v:0,agroup:audio v:1,agroup:audio a:0,agroup:audio" \
+ -hls_segment_filename "$OUT_DIR/v%v/segment_%09d.m4s" \
+ "$OUT_DIR/v%v/stream.m3u8" &
+
+ sleep 2
+ echo ">>> HTTP server: http://localhost:{{ port }}/"
+ cd "$OUT_DIR" && python3 -m http.server {{ port }}
+
+# Publish the clock broadcast.
+clock action url="http://localhost:4443/anon" *args:
+ @if [ "{{ action }}" != "publish" ] && [ "{{ action }}" != "subscribe" ]; then \
+ echo "Error: action must be 'publish' or 'subscribe', got '{{ action }}'" >&2; \
+ exit 1; \
+ fi
+
+ cargo run -p moq-native --example clock -- --url "{{ url }}" --broadcast "clock" {{ args }} {{ action }}
+
+# Connect tokio-console to the publisher (port 6681).
+console:
+ tokio-console http://127.0.0.1:6681
+
+# --- GStreamer ---
+
+# Publish a video using GStreamer to a relay server.
+gst name url='http://localhost:4443/anon' *args:
+ just download "{{ name }}"
+ cargo build -p moq-gst
+
+ GST_PLUGIN_PATH_1_0="${PWD}/../../target/debug${GST_PLUGIN_PATH_1_0:+:$GST_PLUGIN_PATH_1_0}" \
+ gst-launch-1.0 -v -e multifilesrc location="media/{{ name }}.mp4" loop=true ! parsebin name=parse \
+ parse. ! queue ! identity sync=true ! mux.sink_0 \
+ parse. ! queue ! identity sync=true ! mux.sink_1 \
+ moqsink name=mux url="{{ url }}" broadcast="{{ name }}.hang" {{ args }}
+
+# --- Private helpers ---
+
+# Download a test video (already fragmented on vid.moq.dev).
+[private]
+download name:
+ @if [ ! -f "media/{{ name }}.mp4" ]; then \
+ mkdir -p media; \
+ curl -fsSL "https://vid.moq.dev/{{ name }}.mp4" -o "media/{{ name }}.mp4"; \
+ fi
+
+# Convert an h264 input file to CMAF (fmp4) format to stdout.
+[private]
+ffmpeg-cmaf input output='-' *args:
+ ffmpeg -hide_banner -v quiet \
+ -stream_loop -1 -re \
+ -i "{{ input }}" \
+ -c copy \
+ -f mp4 -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame {{ args }} {{ output }}
diff --git a/demo/pub/media/.gitignore b/demo/pub/media/.gitignore
new file mode 100644
index 0000000000..d6b7ef32c8
--- /dev/null
+++ b/demo/pub/media/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/demo/pub/package.json b/demo/pub/package.json
new file mode 100644
index 0000000000..310bfa78bb
--- /dev/null
+++ b/demo/pub/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "video-moq-dev",
+ "private": true,
+ "scripts": {
+ "deploy": "wrangler deploy"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4",
+ "wrangler": "^4"
+ }
+}
diff --git a/demo/pub/tsconfig.json b/demo/pub/tsconfig.json
new file mode 100644
index 0000000000..8a6de976fc
--- /dev/null
+++ b/demo/pub/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "types": ["@cloudflare/workers-types"]
+ }
+}
diff --git a/demo/pub/worker.ts b/demo/pub/worker.ts
new file mode 100644
index 0000000000..1752e8a986
--- /dev/null
+++ b/demo/pub/worker.ts
@@ -0,0 +1,47 @@
+interface Env {
+ VIDEO: R2Bucket;
+}
+
+const CONTENT_TYPES: Record = {
+ ".mp4": "video/mp4",
+ ".webm": "video/webm",
+ ".mkv": "video/x-matroska",
+ ".mov": "video/quicktime",
+ ".ts": "video/mp2t",
+};
+
+export default {
+ async fetch(request: Request, env: Env): Promise {
+ if (request.method !== "GET" && request.method !== "HEAD") {
+ return new Response("Method Not Allowed", { status: 405 });
+ }
+
+ const url = new URL(request.url);
+ const key = url.pathname.slice(1); // Remove leading slash
+
+ if (!key) {
+ return new Response("Not Found", { status: 404 });
+ }
+
+ const object = await env.VIDEO.get(key);
+ if (!object) {
+ return new Response("Not Found", { status: 404 });
+ }
+
+ const dotIdx = key.lastIndexOf(".");
+ const ext = dotIdx >= 0 ? key.substring(dotIdx).toLowerCase() : "";
+ const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
+
+ const headers = {
+ "Content-Type": contentType,
+ "Cache-Control": "public, max-age=2592000",
+ "Content-Length": object.size.toString(),
+ };
+
+ if (request.method === "HEAD") {
+ return new Response(null, { headers });
+ }
+
+ return new Response(object.body, { headers });
+ },
+};
diff --git a/demo/pub/wrangler.jsonc b/demo/pub/wrangler.jsonc
new file mode 100644
index 0000000000..bc34bf3207
--- /dev/null
+++ b/demo/pub/wrangler.jsonc
@@ -0,0 +1,27 @@
+{
+ // Cloudflare Wrangler configuration for the video hosting Worker
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "video-moq-dev",
+ "main": "worker.ts",
+ "compatibility_date": "2025-07-09",
+ "account_id": "dd618f5dbd5da77b8296f1613c301f5c",
+ "workers_dev": false,
+ "route": {
+ "pattern": "vid.moq.dev",
+ "custom_domain": true
+ },
+ // R2 bucket bindings
+ "r2_buckets": [
+ {
+ "binding": "VIDEO",
+ "bucket_name": "video-moq-dev",
+ "remote": true
+ }
+ ],
+ // Observability
+ "observability": {
+ "logs": {
+ "enabled": true
+ }
+ }
+}
diff --git a/demo/relay/.gitignore b/demo/relay/.gitignore
new file mode 100644
index 0000000000..45d7060dc0
--- /dev/null
+++ b/demo/relay/.gitignore
@@ -0,0 +1,8 @@
+*.jwk
+*.jwt
+*.key
+*.pem
+*.crt
+*.csr
+*.cnf
+*.srl
diff --git a/demo/relay/justfile b/demo/relay/justfile
new file mode 100644
index 0000000000..53a8783f43
--- /dev/null
+++ b/demo/relay/justfile
@@ -0,0 +1,93 @@
+set fallback
+
+# Run a localhost relay server without authentication.
+default:
+ TOKIO_CONSOLE_BIND=127.0.0.1:6680 cargo run --bin moq-relay -- localhost.toml
+
+# Run a cluster of relay servers.
+cluster: token
+ bun install
+
+ bun run concurrently --kill-others --names root,leaf0,leaf1,bbb,tos,web --prefix-colors auto \
+ "just root" \
+ "just wait http://localhost:4443/certificate.sha256 && just leaf0" \
+ "just wait http://localhost:4443/certificate.sha256 && just leaf1" \
+ "just wait http://localhost:4444/certificate.sha256 && just pub bbb http://localhost:4444/demo?jwt=$(cat demo-cli.jwt)" \
+ "just wait http://localhost:4443/certificate.sha256 && just pub tos http://localhost:4443/demo?jwt=$(cat demo-cli.jwt)" \
+ "just wait http://localhost:4445/certificate.sha256 && VITE_RELAY_URL=http://localhost:4445/demo?jwt=$(cat demo-web.jwt) bun --cwd ../web --bun vite --open"
+
+# Run a localhost root server, accepting connections from leaf nodes.
+root: (cert "root")
+ cargo run --bin moq-relay -- root.toml
+
+# Run a localhost leaf, connecting to the root server.
+leaf0: (cert "leaf0")
+ cargo run --bin moq-relay -- leaf0.toml
+
+# Run a second localhost leaf, connecting to the root server.
+leaf1: (cert "leaf1")
+ cargo run --bin moq-relay -- leaf1.toml
+
+# Generate a self-signed CA
+ca:
+ #!/usr/bin/env bash
+ set -euo pipefail
+ umask 077
+
+ [ -f ca.pem ] && [ -f ca.key ] && exit 0
+ rm -f ca.pem ca.key
+ printf '[req]\ndistinguished_name = req_dn\n[req_dn]\n' > ca.cnf
+ export OPENSSL_CONF="ca.cnf"
+ openssl req -x509 -sha256 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
+ -days 365 -subj "/CN=moq cluster CA" \
+ -keyout ca.key -out ca.pem
+
+# Generate a self-signed certificate using the root CA.
+
+# Chrome requires a <=14 day validity for self-signed certs.
+cert name: ca
+ #!/usr/bin/env bash
+ set -euo pipefail
+ umask 077
+
+ export OPENSSL_CONF="ca.cnf"
+ openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
+ -subj "/CN={{ name }}" \
+ -keyout "{{ name }}.key" -out "{{ name }}.csr"
+ openssl x509 -req -sha256 -in "{{ name }}.csr" \
+ -CA ca.pem -CAkey ca.key -CAcreateserial \
+ -days 14 -extfile <(printf "subjectAltName=DNS:localhost\n") \
+ -out "{{ name }}.crt"
+ rm "{{ name }}.csr"
+
+# Generate a random secret key for authentication.
+key:
+ #!/usr/bin/env bash
+ set -euo pipefail
+ umask 077
+
+ [ -f root.jwk ] && exit 0
+ rm -f *.jwt
+ cargo run --bin moq-token-cli -- generate --out root.jwk
+
+# Generate authentication tokens for local development.
+token: key
+ #!/usr/bin/env bash
+ set -euo pipefail
+ umask 077
+
+ if [ ! -f demo-web.jwt ]; then
+ cargo run --quiet --bin moq-token-cli -- sign --key root.jwk \
+ --root demo --subscribe "" --publish me \
+ > demo-web.jwt
+ fi
+
+ if [ ! -f demo-cli.jwt ]; then
+ cargo run --quiet --bin moq-token-cli -- sign --key root.jwk \
+ --root demo --publish "" \
+ > demo-cli.jwt
+ fi
+
+# Connect tokio-console to the relay server (port 6680).
+console:
+ tokio-console http://127.0.0.1:6680
diff --git a/demo/relay/leaf0.toml b/demo/relay/leaf0.toml
new file mode 100644
index 0000000000..809dd4dfa3
--- /dev/null
+++ b/demo/relay/leaf0.toml
@@ -0,0 +1,54 @@
+[log]
+# Enable debug logging for development.
+# The RUST_LOG environment variable will take precedence.
+level = "debug"
+
+[server]
+# Listen for QUIC connections on UDP:4444
+# Sometimes IPv6 causes issues; try 127.0.0.1:4444 instead.
+listen = "[::]:4444"
+
+# Serve the same cluster-signed certificate that we present as a client,
+# so other leaves can verify us against `ca.pem`.
+tls.cert = ["leaf0.crt"]
+tls.key = ["leaf0.key"]
+
+# Trust client certificates signed by this CA for mTLS peer auth, so
+# other leaves dialing in as cluster peers can authenticate without a
+# JWT. Without this, inbound leaf-to-leaf connections fall through to
+# the JWT path and are rejected.
+tls.root = ["ca.pem"]
+
+[client]
+# Present this certificate and key on outbound cluster connections.
+# The root maps a valid cert to full access.
+tls.cert = "leaf0.crt"
+tls.key = "leaf0.key"
+
+# Verify the remote's server cert against the cluster CA. All cluster
+# nodes' server certs are signed by `ca.pem`, so no custom trust
+# store is required beyond this one file.
+tls.root = ["ca.pem"]
+
+[web.http]
+# Listen for HTTP and WebSocket (TCP) connections on the given address.
+listen = "[::]:4444"
+
+[cluster]
+# Connect only to the root. leaf1 will dial us directly, forming a loop
+# (leaf1 -> root, leaf1 -> leaf0, leaf0 -> root) to exercise cluster loop detection.
+connect = ["localhost:4443"]
+
+[auth]
+# Allow JWT tokens that are signed by this root key.
+# `just key` will populate this file.
+key = "root.jwk"
+
+[auth.public]
+# Allow anonymous clients to subscribe and publish to the prefixes below.
+subscribe = ["anon", "demo"]
+publish = ["anon", "demo/viewer"]
+
+[stats]
+enabled = true
+node = "local/leaf0"
diff --git a/demo/relay/leaf1.toml b/demo/relay/leaf1.toml
new file mode 100644
index 0000000000..07639fc7a9
--- /dev/null
+++ b/demo/relay/leaf1.toml
@@ -0,0 +1,54 @@
+[log]
+# Enable debug logging for development.
+# The RUST_LOG environment variable will take precedence.
+level = "debug"
+
+[server]
+# Listen for QUIC connections on UDP:4445
+# Sometimes IPv6 causes issues; try 127.0.0.1:4445 instead.
+listen = "[::]:4445"
+
+# Serve the same cluster-signed certificate that we present as a client,
+# so other leaves can verify us against `ca.pem`.
+tls.cert = ["leaf1.crt"]
+tls.key = ["leaf1.key"]
+
+# Trust client certificates signed by this CA for mTLS peer auth, so
+# other leaves dialing in as cluster peers can authenticate without a
+# JWT. Without this, inbound leaf-to-leaf connections fall through to
+# the JWT path and are rejected.
+tls.root = ["ca.pem"]
+
+[client]
+# Present this certificate and key on outbound cluster connections.
+# The root maps a valid cert to full access.
+tls.cert = "leaf1.crt"
+tls.key = "leaf1.key"
+
+# Verify the remote's server cert against the cluster CA. All cluster
+# nodes' server certs are signed by `ca.pem`, so no custom trust
+# store is required beyond this one file.
+tls.root = ["ca.pem"]
+
+[web.http]
+# Listen for HTTP and WebSocket (TCP) connections on the given address.
+listen = "[::]:4445"
+
+[cluster]
+# Connect to both the root and leaf0, forming a loop to exercise cluster loop detection.
+# ex. imagine us-east connects to us-central AND directly to us-west.
+connect = ["localhost:4443", "localhost:4444"]
+
+[auth]
+# Allow JWT tokens that are signed by this root key.
+# `just key` will populate this file.
+key = "root.jwk"
+
+[auth.public]
+# Allow anonymous clients to subscribe and publish to the prefixes below.
+subscribe = ["anon", "demo"]
+publish = ["anon", "demo/viewer"]
+
+[stats]
+enabled = true
+node = "local/leaf1"
diff --git a/demo/relay/localhost.toml b/demo/relay/localhost.toml
new file mode 100644
index 0000000000..be6b25a4ca
--- /dev/null
+++ b/demo/relay/localhost.toml
@@ -0,0 +1,54 @@
+# A simple configuration for a local relay server.
+# This is used for local development and has authentication disabled.
+
+[log]
+# Enable debug logging for development.
+# The RUST_LOG environment variable will take precedence.
+level = "debug"
+
+[server]
+# Listen for QUIC connections on UDP:4443
+# Sometimes IPv6 causes issues; try 127.0.0.1:4443 instead.
+listen = "[::]:4443"
+
+# Generate a self-signed certificate for the given hostnames.
+# 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.
+public = ""
+
+# Publish a single per-node stats broadcast on the cluster origin:
+#
+# .stats/node/local/host
+#
+# carrying four JSON tracks (publisher.json, subscriber.json,
+# internal/publisher.json, internal/subscriber.json) of {path: counters}
+# for every broadcast that has changed within the last `retention`
+# intervals. See doc/bin/relay/config.md for the field semantics.
+[stats]
+enabled = true
+
+# `node` is the suffix that disambiguates per-relay broadcasts in a cluster.
+# It can be multi-segment (e.g. `sjc/1`, `sjc/2`) to nest multiple hosts under
+# a shared region key. Single-node setups can leave it unset.
+node = "local/host"
+
+[iroh]
+# You can optionally enable iroh support for P2P connections.
+# Clients can connect using --iroh-enabled and a `iroh://` URL.
+enabled = false
+
+# Path to persist the iroh secret key.
+# If the file does not exist, a random key will be generated and written to the path.
+# This gives the relay a persistent iroh endpoint id. The secret key can alternatively be set
+# via MOQ_IROH_SECRET environment variable.
+secret = "./iroh-secret.key"
diff --git a/demo/relay/prod.toml b/demo/relay/prod.toml
new file mode 100644
index 0000000000..4726b28ff5
--- /dev/null
+++ b/demo/relay/prod.toml
@@ -0,0 +1,44 @@
+# An example of a production configuration.
+# Here's a real one: https://github.com/moq-dev/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]
+# A single JWK key file for authentication. No kid required in JWTs.
+key = "root.jwk"
+
+# Allow anonymous subscribing for anon/** and demo/**
+# Allow anonymous publishing for anon/** only
+[auth.public]
+subscribe = ["anon", "demo"]
+publish = ["anon", "demo/viewer"]
+
+# For multiple keys with rotation, use a directory instead:
+# key_dir = "/path/to/keys/"
+# Keys are named {kid}.jwk and resolved by the kid in the JWT header.
+
+# Or use a URL for remote key management:
+# key_dir = "https://api.example.com/keys"
diff --git a/demo/relay/root.toml b/demo/relay/root.toml
new file mode 100644
index 0000000000..187fe0102b
--- /dev/null
+++ b/demo/relay/root.toml
@@ -0,0 +1,35 @@
+[log]
+# Enable debug logging for development.
+# The RUST_LOG environment variable will take precedence.
+level = "debug"
+
+[server]
+# Listen for QUIC connections on UDP:4443
+# Sometimes IPv6 causes issues; try 127.0.0.1:4443 instead.
+listen = "[::]:4443"
+
+# Server certificate and key signed by the cluster CA so leaves can
+# verify via `tls.root`. `just cert root` generates these files.
+# For a standalone dev setup, use `tls.generate = ["localhost"]` instead.
+tls.cert = ["root.crt"]
+tls.key = ["root.key"]
+
+# Trust client certificates signed by this CA for mTLS peer auth.
+# Any connection presenting such a cert is granted full access (publish/
+# subscribe/cluster), which is how leaves join without a `cluster.token`
+# JWT. `just ca` generates the CA.
+tls.root = ["ca.pem"]
+
+[web.http]
+# Listen for HTTP and WebSocket (TCP) connections on the given address.
+# Defaults to disabled if not provided.
+listen = "[::]:4443"
+
+[auth]
+# Allow JWT tokens that are signed by this root key.
+key = "root.jwk"
+
+[auth.public]
+# Allow anonymous clients to subscribe and publish to the prefixes below.
+subscribe = ["anon", "demo"]
+publish = ["anon", "demo/viewer"]
diff --git a/demo/sub/justfile b/demo/sub/justfile
new file mode 100644
index 0000000000..411e09ad4a
--- /dev/null
+++ b/demo/sub/justfile
@@ -0,0 +1,9 @@
+set fallback
+
+# Subscribe to a broadcast using GStreamer and render to the screen.
+gst name url='http://localhost:4443/anon' *args:
+ cargo build -p moq-gst
+
+ GST_PLUGIN_PATH_1_0="${PWD}/../../target/debug${GST_PLUGIN_PATH_1_0:+:$GST_PLUGIN_PATH_1_0}" \
+ gst-launch-1.0 -v -e moqsrc url="{{ url }}" broadcast="{{ name }}" {{ args }} \
+ ! decodebin3 ! videoconvert ! autovideosink
diff --git a/demo/throttle/enable b/demo/throttle/enable
new file mode 100755
index 0000000000..aa62d703b8
--- /dev/null
+++ b/demo/throttle/enable
@@ -0,0 +1,104 @@
+#!/bin/bash
+#
+# Network throttle for local development (macOS)
+# Simulates a constrained network for QUIC testing.
+# Runs until Ctrl+C, then restores original settings.
+
+set -e
+
+# Hard-coded profile: 2Mbps, 50ms delay, 100 packet queue
+BANDWIDTH="2Mbit"
+DELAY="50ms"
+QUEUE="100"
+
+PIPE_NUM=1
+PF_ANCHOR="moq-throttle"
+PF_CONF="/etc/pf.conf"
+PF_BACKUP="/tmp/pf.conf.backup"
+
+# State tracking
+PF_WAS_ENABLED=""
+CLEANUP_DONE=""
+
+capture_pf_state() {
+ if sudo pfctl -s info 2>/dev/null | grep -q "Status: Enabled"; then
+ PF_WAS_ENABLED="yes"
+ else
+ PF_WAS_ENABLED="no"
+ fi
+}
+
+cleanup() {
+ local exit_code=$?
+
+ # Guard against running cleanup multiple times
+ [ -n "$CLEANUP_DONE" ] && return
+ CLEANUP_DONE="yes"
+
+ echo ""
+ echo "Stopping network throttle..."
+
+ # Flush anchor rules
+ sudo pfctl -a "$PF_ANCHOR" -F all 2>/dev/null || true
+
+ # Delete dummynet pipe
+ sudo dnctl pipe $PIPE_NUM delete 2>/dev/null || true
+
+ # Restore original PF config
+ if [ -f "$PF_BACKUP" ]; then
+ sudo cp "$PF_BACKUP" "$PF_CONF"
+ sudo pfctl -f "$PF_CONF" 2>/dev/null || true
+ sudo rm "$PF_BACKUP"
+ fi
+
+ # Restore PF enabled/disabled state (only disable if we enabled it)
+ if [ "$PF_WAS_ENABLED" = "no" ]; then
+ sudo pfctl -d 2>/dev/null || true
+ fi
+
+ echo "Throttling disabled."
+ exit "$exit_code"
+}
+
+# Register cleanup for all exit paths
+trap cleanup EXIT
+
+echo "Starting network throttle..."
+echo " Bandwidth: $BANDWIDTH"
+echo " Delay: $DELAY"
+echo " Queue: $QUEUE packets"
+
+# Capture PF state before any changes
+capture_pf_state
+
+# Back up pf.conf
+sudo cp "$PF_CONF" "$PF_BACKUP"
+
+# Modify pf.conf: remove lo0 skip and add our anchor point
+{
+ grep -v "set skip on lo0" "$PF_BACKUP"
+ echo "dummynet-anchor \"$PF_ANCHOR\""
+ echo "anchor \"$PF_ANCHOR\""
+} | sudo tee "$PF_CONF" >/dev/null
+
+# Configure dummynet pipe
+sudo dnctl pipe $PIPE_NUM config bw "$BANDWIDTH" delay "$DELAY" queue "$QUEUE"
+
+# Load modified pf config
+sudo pfctl -f "$PF_CONF" 2>/dev/null
+
+# Enable PF if it wasn't already enabled
+if [ "$PF_WAS_ENABLED" = "no" ]; then
+ sudo pfctl -E 2>/dev/null || true
+fi
+
+# Add throttling rules for outbound UDP only
+echo "dummynet out proto udp all pipe $PIPE_NUM" | sudo pfctl -a "$PF_ANCHOR" -f -
+
+echo "Throttling enabled. Press Ctrl+C to stop."
+echo ""
+
+# Wait forever until interrupted
+while true; do
+ sleep 1
+done
diff --git a/demo/web/.env b/demo/web/.env
new file mode 100644
index 0000000000..a416e700f5
--- /dev/null
+++ b/demo/web/.env
@@ -0,0 +1 @@
+VITE_RELAY_URL=http://localhost:4443/anon
diff --git a/demo/web/README.md b/demo/web/README.md
new file mode 100644
index 0000000000..7ce9e1ac8e
--- /dev/null
+++ b/demo/web/README.md
@@ -0,0 +1,23 @@
+
+
+
+
+Media over QUIC (MoQ) is a live (media) delivery protocol utilizing QUIC.
+It utilizes new browser technologies such as [WebTransport](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API) and [WebCodecs](https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API) to provide WebRTC-like functionality.
+Despite the focus on media, the transport is generic and designed to scale to enormous viewership via clustered relay servers (aka a CDN).
+See [moq.dev](https://moq.dev) for more information.
+
+**Note:** this project is a [fork](https://moq.dev/blog/transfork) of the [IETF specification](https://datatracker.ietf.org/group/moq/documents/).
+The principles are the same but the implementation is exponentially simpler given a narrower focus (and no politics).
+
+# Usage
+
+These are demos, duh.
+We're using Vite but other bundlers should work too.
+
+# License
+
+Licensed under either:
+
+- Apache License, Version 2.0, ([LICENSE-APACHE](../../LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
+- MIT license ([LICENSE-MIT](../../LICENSE-MIT) or http://opensource.org/licenses/MIT)
diff --git a/demo/web/justfile b/demo/web/justfile
new file mode 100644
index 0000000000..9f279a42a8
--- /dev/null
+++ b/demo/web/justfile
@@ -0,0 +1,9 @@
+set shell := ["bash", "-euo", "pipefail", "-c"]
+
+# Run the web server targeting the default relay.
+default:
+ just serve
+
+# Run the web server targeting the specified relay.
+serve url='http://localhost:4443/anon':
+ VITE_RELAY_URL="{{ url }}" bun --bun vite --open
diff --git a/demo/web/package.json b/demo/web/package.json
new file mode 100644
index 0000000000..0373bb5a54
--- /dev/null
+++ b/demo/web/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@moq/demo",
+ "private": true,
+ "type": "module",
+ "version": "0.1.1",
+ "description": "Media over QUIC - Demo",
+ "license": "(MIT OR Apache-2.0)",
+ "repository": "github:moq-dev/moq",
+ "scripts": {
+ "dev": "vite --open",
+ "build": "vite build",
+ "check": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@moq/hang": "workspace:^",
+ "@moq/watch": "workspace:^",
+ "@moq/publish": "workspace:^"
+ },
+ "//devDependencies": "These are only needed for local development with workspace packages. They are NOT needed when using the published npm packages.",
+ "devDependencies": {
+ "@tailwindcss/typography": "^0.5.16",
+ "@tailwindcss/vite": "^4.1.13",
+ "esbuild": "^0.27.0",
+ "highlight.js": "^11.11.1",
+ "solid-element": "^1.9.1",
+ "solid-js": "^1.9.10",
+ "tailwindcss": "^4.1.13",
+ "typescript": "^6.0.0",
+ "vite": "^7.3.1",
+ "vite-plugin-solid": "^2.11.10"
+ }
+}
diff --git a/demo/web/src/discover.ts b/demo/web/src/discover.ts
new file mode 100644
index 0000000000..0c2b8f50f0
--- /dev/null
+++ b/demo/web/src/discover.ts
@@ -0,0 +1,90 @@
+import { Moq, Signals } from "@moq/hang";
+import type MoqWatch from "@moq/watch/element";
+
+/**
+ * Wraps a element and live discovers new broadcasts available at the given URL.
+ * Displays clickable broadcast names above the player.
+ */
+export default class MoqDiscover extends HTMLElement {
+ #suggestions: HTMLDivElement;
+ #signals = new Signals.Effect();
+
+ constructor() {
+ super();
+
+ // Create suggestions container
+ this.#suggestions = document.createElement("div");
+ this.#suggestions.style.cssText = "margin-bottom: 0.5rem; font-size: 0.85rem;";
+ }
+
+ async connectedCallback() {
+ this.style.cssText = "display: block; margin: 1rem 0;";
+
+ // Discover the inner moq-watch element.
+ await customElements.whenDefined("moq-watch");
+ const watch = this.querySelector("moq-watch") as MoqWatch | null;
+ if (!watch) return;
+
+ // Insert the suggestions above the existing children.
+ this.prepend(this.#suggestions);
+
+ // Reactively render suggestions when broadcasts or selected name changes.
+ this.#signals.run((effect) => {
+ const broadcasts = effect.get(watch.connection.announced);
+ const selected = effect.get(watch.broadcast.name).toString();
+
+ this.#clearSuggestions();
+
+ if (broadcasts.size === 0) return;
+
+ const label = document.createElement("span");
+ label.textContent = "Available: ";
+ label.style.color = "#666";
+ this.#suggestions.appendChild(label);
+
+ for (const name of broadcasts) {
+ const isSelected = name === selected;
+ const tag = document.createElement("button");
+ tag.type = "button";
+ tag.textContent = name;
+
+ const defaultBg = isSelected ? "#2d4a2d" : "#1a2e1a";
+ const defaultBorder = isSelected ? "#4ade80" : "#2d4a2d";
+
+ tag.style.cssText = `
+ background: ${defaultBg}; color: #4ade80; border: 1px solid ${defaultBorder};
+ padding: 0.2rem 0.5rem; margin: 0 0.25rem; border-radius: 4px;
+ font-size: 0.8rem; font-family: monospace; cursor: pointer;
+ font-weight: ${isSelected ? "bold" : "normal"};
+ transition: background 0.15s, border-color 0.15s;
+ `;
+ if (!isSelected) {
+ tag.addEventListener("mouseenter", () => {
+ tag.style.background = "#2d4a2d";
+ tag.style.borderColor = "#4ade80";
+ });
+ tag.addEventListener("mouseleave", () => {
+ tag.style.background = defaultBg;
+ tag.style.borderColor = defaultBorder;
+ });
+ }
+ tag.addEventListener("click", () => {
+ watch.broadcast.name.set(Moq.Path.from(name));
+ });
+ this.#suggestions.appendChild(tag);
+ }
+ });
+ }
+
+ disconnectedCallback() {
+ this.#signals.close();
+ }
+
+ #clearSuggestions() {
+ while (this.#suggestions.firstChild) {
+ this.#suggestions.removeChild(this.#suggestions.firstChild);
+ }
+ }
+}
+
+customElements.define("moq-discover", MoqDiscover);
diff --git a/js/hang-demo/src/favicon.svg b/demo/web/src/favicon.svg
similarity index 100%
rename from js/hang-demo/src/favicon.svg
rename to demo/web/src/favicon.svg
diff --git a/js/hang-demo/src/highlight.ts b/demo/web/src/highlight.ts
similarity index 100%
rename from js/hang-demo/src/highlight.ts
rename to demo/web/src/highlight.ts
diff --git a/js/hang-demo/src/index.css b/demo/web/src/index.css
similarity index 100%
rename from js/hang-demo/src/index.css
rename to demo/web/src/index.css
diff --git a/demo/web/src/index.html b/demo/web/src/index.html
new file mode 100644
index 0000000000..2c5c876366
--- /dev/null
+++ b/demo/web/src/index.html
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+ MoQ Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Other demos:
+
+
+ Tips:
+
+ You can find the source code for this demo in dev/web/src/index.html.
+ Yes I know it's confusing when a command automatically opens a browser window.
+
+
+ This demo uses
+ http so it's extra not secure.
+ It works by insecurely fetching the certificate hash and telling WebTransport to trust it.
+ If you're going to run this code in production, you'll need a valid certificate (ex. LetsEncrypt) and use
+ https.
+
+
+
+ You can instanciate the player via the provided <moq-watch> Web Component .
+ Either modify HTML attributes like <moq-watch paused> or use the
+ Javascript API.
+ The Javascript API is still evolving, so I recommend the Web Component for now.
+
+
+ You can provide your own canvas element and use CSS to modify it.
+ Unfortunately, you can't use the HTML width/height attributes because of how OffscreenCanvas
+ works.
+ For example:
+
<moq-watch url="http://localhost:4443/" name="bbb.hang">
+ <!-- Optionally provide a custom canvas element that we can style as needed -->
+ <canvas style="max-width: 100%; height: auto; border-radius: 4px;"></canvas>
+</moq-watch>
+
+
+ Don't want video? Don't provide a canvas!
+ It won't be downloaded, decoded, or rendered.
+ This includes when the video is paused, minimized, not in the DOM, or scrolled out of view.
+ wowee the bandwidth savings!
+
+
+ Audio may start muted because the browser can require user interaction before autoplaying.
+ You can unmute it by removing the muted property or calling watch.audio.muted.set(false) via the Javascript API.
+ And of course, nothing is downloaded while it's muted.
+
+
+
+ The Javascript API is far more powerful and you can access properties directly:
+
+
const watch = document.getElementById("watch");
+watch.audio.muted.set(true);
+
+
+
+
+ All of the properties are reactive using a hand-rolled signals library: `@moq/signals`.
+ You could use it... or you can use the provided `react` and `solid` helpers:
+
+
+import { Watch } from "@moq/hang";
+import solid from "@moq/signals/solid";
+
+function Volume(hang: Watch) {
+ // Switch to `react` if you're using React, duh.
+ const volume = solid(hang.volume);
+
+ // Return a div that displays the volume.
+ return <div>
+ Volume: {volume()}
+ </div>
+}
+
+
+
+ Using something more niche? There's also a subscribe() method to
+ trigger a callback on change.
+
+
+const cleanup = hang.volume.subscribe((volume) => {
+ document.getElementById("volume-value").textContent = `${volume * 100}%`;
+});
+
+// Cleanup the subscription when no longer needed.
+cleanup();
+
+
+
+
+ The connection and broadcast are automatically reloaded.
+ Try running multiple terminals and kill the broadcast to see what happens.
+
+
# Run the relay and web server in another terminal or the background.
+just relay &
+just web &
+
+just pub bbb
+# Kill it with ctrl+C
+
+# Republish the same broadcast, the player will reconnect.
+just pub bbb
+
+
+
+ If the Big Bunny is making you sick, you can use other inferior test videos or the publish demo .
+ For example,
+ Try running just pub tos in a new terminal and then watch robots bang .
+ This command uses ffmpeg to produce a fragmented MP4 file piped over stdout and then sent over the network.
+ Yeah it's pretty gross.
+
+
+ If you want to do things more efficiently, you can use the
+ GStreamer plugin .
+ It's pretty crude and doesn't handle all pipeline events; contributions welcome!
+
+
+
+
+
+
diff --git a/demo/web/src/index.ts b/demo/web/src/index.ts
new file mode 100644
index 0000000000..a90f3eabfc
--- /dev/null
+++ b/demo/web/src/index.ts
@@ -0,0 +1,18 @@
+import "./highlight";
+import "@moq/watch/ui";
+import MoqWatch from "@moq/watch/element";
+import MoqWatchSupport from "@moq/watch/support/element";
+import MoqDiscover from "./discover";
+
+export { MoqDiscover, MoqWatch, MoqWatchSupport };
+
+const watch = document.querySelector("moq-watch") as MoqWatch | undefined;
+if (!watch) throw new Error("unable to find element");
+
+// If query params are provided, use them.
+const urlParams = new URLSearchParams(window.location.search);
+const name = urlParams.get("broadcast") ?? urlParams.get("name");
+const url = urlParams.get("url");
+
+if (url) watch.url = url;
+if (name) watch.name = name;
diff --git a/demo/web/src/manual.html b/demo/web/src/manual.html
new file mode 100644
index 0000000000..fa29995637
--- /dev/null
+++ b/demo/web/src/manual.html
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+ MoQ Demo (Manual Catalog)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Catalog JSON
+
+ Paste a Catalog.Root blob and click Apply .
+ The element won't fetch a catalog track in manual mode — media tracks are
+ subscribed off whatever you provide here.
+
+
+ To grab a real one, open the WebCodecs demo ,
+ let it play, then run JSON.stringify(document.getElementById("watch").catalog)
+ in the dev console and paste the result here.
+
+
+
+
+
+
+ Apply
+
+
+
+
+
+ Other demos:
+
+
+
+
+
+
diff --git a/demo/web/src/manual.ts b/demo/web/src/manual.ts
new file mode 100644
index 0000000000..1d2624d96b
--- /dev/null
+++ b/demo/web/src/manual.ts
@@ -0,0 +1,51 @@
+import "./highlight";
+import "@moq/watch/ui";
+import * as Catalog from "@moq/hang/catalog";
+import MoqWatch from "@moq/watch/element";
+import MoqWatchSupport from "@moq/watch/support/element";
+
+export { MoqWatch, MoqWatchSupport };
+
+const watch = document.querySelector("moq-watch") as MoqWatch | null;
+if (!watch) throw new Error("missing element");
+
+const input = document.getElementById("catalog-input") as HTMLTextAreaElement;
+const apply = document.getElementById("apply") as HTMLButtonElement;
+const status = document.getElementById("status") as HTMLSpanElement;
+
+const urlParams = new URLSearchParams(window.location.search);
+const name = urlParams.get("broadcast") ?? urlParams.get("name");
+const url = urlParams.get("url");
+if (url) watch.url = url;
+if (name) watch.name = name;
+
+function setStatus(msg: string, ok = true) {
+ status.textContent = msg;
+ status.style.color = ok ? "" : "tomato";
+}
+
+apply.addEventListener("click", () => {
+ const text = input.value.trim();
+ if (!text) {
+ watch.catalog = undefined;
+ setStatus("cleared");
+ return;
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text);
+ } catch (err) {
+ setStatus(`parse error: ${(err as Error).message}`, false);
+ return;
+ }
+
+ const result = Catalog.RootSchema.safeParse(parsed);
+ if (!result.success) {
+ setStatus(`invalid catalog: ${result.error.message}`, false);
+ return;
+ }
+
+ watch.catalogFormat = "manual";
+ watch.catalog = result.data;
+ setStatus("applied");
+});
diff --git a/demo/web/src/mse.html b/demo/web/src/mse.html
new file mode 100644
index 0000000000..ed723fb1c0
--- /dev/null
+++ b/demo/web/src/mse.html
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+ MoQ Demo (MSE)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Other demos:
+
+
+ Tips:
+
+ This demo uses MSE (Media
+ Source Extensions)
+ via a <video> element instead of WebCodecs via <canvas>.
+ MSE has broader device support but higher latency.
+
+
+ To use MSE, provide a <video> element instead of a <canvas> element:
+
<moq-watch url="https://cdn.moq.dev/anon" name="bbb.hang">
+ <video style="max-width: 100%; height: auto;" autoplay muted></video>
+</moq-watch>
+
+
+
+
+
+
diff --git a/demo/web/src/publish.html b/demo/web/src/publish.html
new file mode 100644
index 0000000000..610940cf1e
--- /dev/null
+++ b/demo/web/src/publish.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+ MoQ Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Other demos:
+
+
+ Tips:
+
+ This page creates a broadcast called `me.hang` by default.
+ You can use query parameters to use a different broadcast name and create
+ multiple broadcasts.
+
+
+ Reusing the same broadcast path means viewers will automatically reconnect to the new session.
+ Try reloading the page and broadcasting again; viewers will automatically reconnect.
+
+
+
+ Media only flows over the network when requested!
+ Connecting to a relay means the broadcast is advertised as available, but nothing is transferred until there's
+ at least one viewer per track.
+ If there's multiple viewers, the relay will fan out the media to all of them.
+
+
+ You can create a broadcaster via the provided <moq-publish> Web Component .
+ Either modify HTML attributes like <moq-publish source="camera" audio />
+ or access the element's Javascript API:
+
const publish = document.getElementById("publish");
+publish.broadcast.audio.enabled.set(true);
+
+ And of course you can use the Javascript API directly instead of the Web Component.
+ It's a bit more complicated and subject to change, but it gives you more control.
+
+
+
+ You're not limited to web publishing either.
+ Try running just pub tos in a new terminal and then watch robots bang .
+ This uses ffmpeg to produce a fragmented MP4 file piped over stdout then sent over the network.
+ Yeah it's pretty gross.
+
+
+ If you want to do things more efficiently, you can use the
+ GStreamer plugin .
+ It's pretty crude and doesn't handle all pipeline events; contributions welcome!
+
+
+
+ This demo uses `http://` so it's not secure.
+ It works by fetching the certificate hash (via HTTP) and providing that to WebTransport, which requires HTTPS.
+ To run this in production, you'll need a valid certificate (ex. letsencrypt) and to use `https://`.
+
+
+
+
+
+
diff --git a/demo/web/src/publish.ts b/demo/web/src/publish.ts
new file mode 100644
index 0000000000..68622af530
--- /dev/null
+++ b/demo/web/src/publish.ts
@@ -0,0 +1,20 @@
+import "./highlight";
+import "@moq/publish/ui";
+
+// We need to import Web Components with fully-qualified paths because of tree-shaking.
+import MoqPublish from "@moq/publish/element";
+import MoqPublishSupport from "@moq/publish/support/element";
+
+export { MoqPublish, MoqPublishSupport };
+
+const publish = document.querySelector("moq-publish") as MoqPublish;
+const watch = document.getElementById("watch") as HTMLAnchorElement;
+const watchName = document.getElementById("watch-name") as HTMLSpanElement;
+
+const urlParams = new URLSearchParams(window.location.search);
+const name = urlParams.get("broadcast") ?? urlParams.get("name");
+if (name) {
+ publish.setAttribute("name", name);
+ watch.href = `index.html?broadcast=${name}`;
+ watchName.textContent = name;
+}
diff --git a/js/hang-demo/src/vite-env.d.ts b/demo/web/src/vite-env.d.ts
similarity index 100%
rename from js/hang-demo/src/vite-env.d.ts
rename to demo/web/src/vite-env.d.ts
diff --git a/js/hang-demo/tailwind.config.js b/demo/web/tailwind.config.js
similarity index 100%
rename from js/hang-demo/tailwind.config.js
rename to demo/web/tailwind.config.js
diff --git a/demo/web/tsconfig.json b/demo/web/tsconfig.json
new file mode 100644
index 0000000000..a8f92e3998
--- /dev/null
+++ b/demo/web/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../js/tsconfig.json",
+ "compilerOptions": {
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "outDir": "dist"
+ },
+ "include": ["src", "../../js/common/worklet.d.ts"]
+}
diff --git a/demo/web/vite.config.ts b/demo/web/vite.config.ts
new file mode 100644
index 0000000000..5cd3ce3572
--- /dev/null
+++ b/demo/web/vite.config.ts
@@ -0,0 +1,31 @@
+import tailwindcss from "@tailwindcss/vite";
+import { resolve } from "path";
+import { defineConfig } from "vite";
+import solidPlugin from "vite-plugin-solid";
+import { workletInline } from "../../js/common/vite-plugin-worklet";
+
+export default defineConfig({
+ root: "src",
+ envDir: resolve(__dirname),
+ plugins: [tailwindcss(), solidPlugin(), workletInline()],
+ build: {
+ target: "esnext",
+ sourcemap: process.env.NODE_ENV === "production" ? false : "inline",
+ rollupOptions: {
+ input: {
+ watch: resolve(__dirname, "src/index.html"),
+ publish: resolve(__dirname, "src/publish.html"),
+ mse: resolve(__dirname, "src/mse.html"),
+ manual: resolve(__dirname, "src/manual.html"),
+ },
+ },
+ },
+ server: {
+ // TODO: properly support HMR
+ hmr: false,
+ },
+ optimizeDeps: {
+ // No idea why this needs to be done, but I don't want to figure it out.
+ exclude: ["@libav.js/variant-opus-af"],
+ },
+});
diff --git a/deno.json b/deno.json
deleted file mode 100644
index 83640dbd92..0000000000
--- a/deno.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "unstable": ["sloppy-imports", "net"]
-}
diff --git a/deno.lock b/deno.lock
deleted file mode 100644
index 8e95c842cf..0000000000
--- a/deno.lock
+++ /dev/null
@@ -1,123 +0,0 @@
-{
- "version": "5",
- "specifiers": {
- "jsr:@std/cli@*": "1.0.21"
- },
- "jsr": {
- "@std/cli@1.0.21": {
- "integrity": "cd25b050bdf6282e321854e3822bee624f07aca7636a3a76d95f77a3a919ca2a"
- }
- },
- "workspace": {
- "packageJson": {
- "dependencies": [
- "npm:@biomejs/biome@^2.3.11",
- "npm:concurrently@^9.2.1"
- ]
- },
- "members": {
- "doc": {
- "packageJson": {
- "dependencies": [
- "npm:vitepress@^1.6.4",
- "npm:wrangler@^4.56.0"
- ]
- }
- },
- "js/clock": {
- "packageJson": {
- "dependencies": [
- "npm:@types/deno@^2.3.0",
- "npm:typescript@^5.9.2"
- ]
- }
- },
- "js/hang": {
- "packageJson": {
- "dependencies": [
- "npm:@kixelated/libavjs-webcodecs-polyfill@~0.5.5",
- "npm:@libav.js/variant-opus-af@^6.8.8",
- "npm:@types/audioworklet@^0.0.77",
- "npm:@types/web@^0.0.241",
- "npm:async-mutex@0.5",
- "npm:comlink@^4.4.2",
- "npm:fast-glob@^3.3.3",
- "npm:rimraf@^6.0.1",
- "npm:typescript@^5.9.2",
- "npm:vitest@^3.2.4",
- "npm:zod@^4.1.5"
- ]
- }
- },
- "js/hang-demo": {
- "packageJson": {
- "dependencies": [
- "npm:@tailwindcss/typography@~0.5.16",
- "npm:@tailwindcss/vite@^4.1.13",
- "npm:highlight.js@^11.11.1",
- "npm:tailwindcss@^4.1.13",
- "npm:typescript@^5.9.2",
- "npm:vite-plugin-solid@^2.11.10",
- "npm:vite@^6.3.6"
- ]
- }
- },
- "js/hang-ui": {
- "packageJson": {
- "dependencies": [
- "npm:@solidjs/testing-library@~0.8.10",
- "npm:@testing-library/jest-dom@^6.9.1",
- "npm:happy-dom@^20.0.11",
- "npm:rimraf@^6.0.1",
- "npm:solid-element@^1.9.1",
- "npm:solid-js@^1.9.10",
- "npm:typescript@^5.9.2",
- "npm:unplugin-solid@1",
- "npm:vite-plugin-solid@^2.11.10",
- "npm:vite@^7.3.1",
- "npm:vitest@^3.2.4"
- ]
- }
- },
- "js/lite": {
- "packageJson": {
- "dependencies": [
- "npm:@moq/web-transport-ws@~0.1.2",
- "npm:@types/deno@^2.3.0",
- "npm:@types/node@^24.3.1",
- "npm:@types/web@^0.0.241",
- "npm:async-mutex@0.5",
- "npm:madge@8",
- "npm:rimraf@^6.0.1",
- "npm:typescript@^5.9.2",
- "npm:vite-plugin-html@^3.2.2",
- "npm:vite@^6.3.6"
- ]
- }
- },
- "js/signals": {
- "packageJson": {
- "dependencies": [
- "npm:@types/react@^19.1.8",
- "npm:dequal@^2.0.3",
- "npm:rimraf@^6.0.1",
- "npm:solid-js@^1.9.7",
- "npm:typescript@^5.9.2"
- ]
- }
- },
- "js/token": {
- "packageJson": {
- "dependencies": [
- "npm:@hexagon/base64@^2.0.4",
- "npm:@types/node@^24.3.1",
- "npm:commander@^14.0.2",
- "npm:jose@^6.1.0",
- "npm:typescript@^5.9.2",
- "npm:zod@^4.1.5"
- ]
- }
- }
- }
- }
-}
diff --git a/deny.toml b/deny.toml
new file mode 100644
index 0000000000..b3548456fe
--- /dev/null
+++ b/deny.toml
@@ -0,0 +1,63 @@
+# cargo-deny configuration. See https://embarkstudios.github.io/cargo-deny/
+#
+# Run `cargo deny check` to validate; runs in `just rs check` and `just rs ci`.
+
+[graph]
+all-features = true
+
+[advisories]
+version = 2
+yanked = "deny"
+ignore = [
+ # rsa 0.9.x Marvin Attack (RSA private-key timing sidechannel). The
+ # `rsa` crate is a direct dep of moq-token but is only used for keygen
+ # and PEM serialization of JWK components. JWT signing/verification
+ # itself runs through `jsonwebtoken` backed by `aws-lc-rs` (constant
+ # time), so the vulnerable code path is not exercised. Drop the ignore
+ # when rustcrypto/RSA publishes a constant-time fix.
+ "RUSTSEC-2023-0071",
+
+ # foundations (Cloudflare) pins serde_yaml 0.8 which uses yaml-rust 0.4.
+ # Reachable via tokio-quiche -> web-transport-quiche -> moq-native
+ # (quiche feature). Awaits upstream migration to serde_yaml 0.9+.
+ "RUSTSEC-2024-0320",
+
+ # gstreamer 0.23 pulls paste (proc-macro). gstreamer 0.25 drops it but
+ # requires Rust 1.92; workspace MSRV is 1.85. Build-time only.
+ "RUSTSEC-2024-0436",
+
+ # http-cache (via http-cache-reqwest in moq-relay) still depends on
+ # bincode 1.x. Awaits upstream bump to bincode 2.x.
+ "RUSTSEC-2025-0141",
+]
+
+[licenses]
+version = 2
+confidence-threshold = 0.9
+allow = [
+ "0BSD",
+ "Apache-2.0",
+ "Apache-2.0 WITH LLVM-exception",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "CC0-1.0",
+ "CDLA-Permissive-2.0", # webpki-roots cert bundle
+ "ISC",
+ "MIT",
+ "MPL-2.0",
+ "Unicode-3.0",
+ "Unlicense",
+ "WTFPL", # ffmpeg-next, ffmpeg-sys-next
+ "Zlib",
+]
+
+[bans]
+multiple-versions = "warn"
+wildcards = "deny"
+# Path/workspace deps don't carry versions; that's fine for intra-workspace use.
+allow-wildcard-paths = true
+
+[sources]
+unknown-registry = "deny"
+unknown-git = "deny"
+allow-registry = ["https://github.com/rust-lang/crates.io-index"]
diff --git a/dev/leaf.toml b/dev/leaf.toml
deleted file mode 100644
index f623d71c1c..0000000000
--- a/dev/leaf.toml
+++ /dev/null
@@ -1,69 +0,0 @@
-[log]
-# Enable debug logging for development.
-# The RUST_LOG environment variable will take precedence.
-level = "debug"
-
-[server]
-# 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.
-# Announcements flow from leaf -> root -> leaf, but any subscriptions are leaf -> leaf.
-# The root node can serve (user) subscriptions too.
-#
-# The root node is either missing the "root" field below or it's identifical to the "node" field.
-# This node acts a server only, accepting incoming connections from leaf nodes and users alike.
-#
-# There can be any number of leaf nodes.
-# These nodes will connect to the specified root address and announce themselves via MoQ as a "broadcast".
-# All nodes will discover these broadcasts and connect to other nodes.
-#
-# This forms an NxN mesh of nodes.
-# Broadcasts are announced between all nodes with no collision detection, so duplicates are possible.
-# Subscriptions will be relayed from leaf to leaf, so at most you can have:
-# user -> leaf -> leaf -> user
-[cluster]
-# Connect to this hostname in order to discover other nodes.
-connect = "localhost:4443"
-
-# Use the token in this file when connecting to other nodes.
-# `just auth-token` will populate this file.
-token = "dev/root.jwt"
-
-# My hostname, which must be accessible from other nodes.
-node = "localhost:4444"
-
-# Each leaf node will connect to the root node and other nodes, using this configuration.
-[client]
-# QUIC uses TLS to have the client verify the server's identity.
-# However if you're not worried about man-in-the-middle attacks, you can disable verification:
-tls.disable_verify = true
-
-# A better approach is to generate a server certificate and configure the client to accept it.
-#
-# If the server has a certificate generated by a public root CA (ex. Let's Encrypt), then that will work if the client connects to the indiciated domain.
-# ex. a cert for *.moq.dev will only be allowed if the root/node configuration below matches.
-#
-# Alternatively, you can generate a self-signed root CA and configure the client to accept it.
-# tls.root = ["/path/to/root.pem"]
-#
-# This can be much more secure because the server doesn't need to be publically accessible.
-# ex. You could host the root at a private `.internal` domain and generate a matching certificate.
-
-[auth]
-# `just auth-key` will populate this file.
-key = "dev/root.jwk"
-
-# Allow anonymous publishing and subscribing for this prefix.
-public = "anon"
diff --git a/dev/prod.toml b/dev/prod.toml
deleted file mode 100644
index 40805ca639..0000000000
--- a/dev/prod.toml
+++ /dev/null
@@ -1,33 +0,0 @@
-# An example of a production configuration.
-# Here's a real one: https://github.com/moq-dev/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]
-# Path to the authentication key file:
-# - For asymmetric keys (RS256, ES256, etc.): use the public key (e.g., "public.jwk")
-# - For symmetric keys (HS256, HS384, HS512): use the shared secret key (e.g., "secret.jwk")
-key = "public.jwk"
diff --git a/dev/relay.toml b/dev/relay.toml
deleted file mode 100644
index c954f9ee16..0000000000
--- a/dev/relay.toml
+++ /dev/null
@@ -1,38 +0,0 @@
-# A simple configuration for a local relay server.
-# This is used for local development and has authentication disabled.
-
-[log]
-# Enable debug logging for development.
-# The RUST_LOG environment variable will take precedence.
-level = "debug"
-
-[server]
-# Listen for QUIC connections on UDP:4443
-# Sometimes IPv6 causes issues; try 127.0.0.1:4443 instead.
-listen = "[::]:4443"
-
-# Generate a self-signed certificate for the given hostnames.
-# 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.
-public = ""
-
-[iroh]
-# You can optionally enable iroh support for P2P connections.
-# Clients can connect using --iroh-enabled and a `iroh://` URL.
-enabled = false
-
-# Path to persist the iroh secret key.
-# If the file does not exist, a random key will be generated and written to the path.
-# This gives the relay a persistent iroh endpoint id. The secret key can alternatively be set
-# via MOQ_IROH_SECRET environment variable.
-secret = "./dev/relay-iroh-secret.key"
diff --git a/dev/root.toml b/dev/root.toml
deleted file mode 100644
index 8b8140de17..0000000000
--- a/dev/root.toml
+++ /dev/null
@@ -1,34 +0,0 @@
-[log]
-# Enable debug logging for development.
-# The RUST_LOG environment variable will take precedence.
-level = "debug"
-
-[server]
-# Listen for QUIC connections on UDP:4443
-# Sometimes IPv6 causes issues; try 127.0.0.1:4443 instead.
-listen = "[::]:4443"
-
-# Generate a self-signed certificate for the given hostnames.
-# 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]]
-# chain = "dev/moq.dev.pem"
-# key = "dev/moq.dev.key"
-
-# Authentication is rather crude because it's GOOD ENOUGH for now.
-# see docs/auth.md for more information.
-[auth]
-# `just auth-key` will generate a root key.
-# If you want to disable authentication, don't specify a key.
-key = "dev/root.jwk"
-
-# Allow anonymous publishing and subscribing for anon/**
-public = "anon"
diff --git a/dev/throttle b/dev/throttle
deleted file mode 100755
index 844ceabbd1..0000000000
--- a/dev/throttle
+++ /dev/null
@@ -1,104 +0,0 @@
-#!/bin/bash
-#
-# Network throttle for local development (macOS)
-# Simulates a constrained network for QUIC testing.
-# Runs until Ctrl+C, then restores original settings.
-
-set -e
-
-# Hard-coded profile: 2Mbps, 50ms delay, 100 packet queue
-BANDWIDTH="2Mbit"
-DELAY="50ms"
-QUEUE="100"
-
-PIPE_NUM=1
-PF_ANCHOR="moq-throttle"
-PF_CONF="/etc/pf.conf"
-PF_BACKUP="/tmp/pf.conf.backup"
-
-# State tracking
-PF_WAS_ENABLED=""
-CLEANUP_DONE=""
-
-capture_pf_state() {
- if sudo pfctl -s info 2>/dev/null | grep -q "Status: Enabled"; then
- PF_WAS_ENABLED="yes"
- else
- PF_WAS_ENABLED="no"
- fi
-}
-
-cleanup() {
- local exit_code=$?
-
- # Guard against running cleanup multiple times
- [ -n "$CLEANUP_DONE" ] && return
- CLEANUP_DONE="yes"
-
- echo ""
- echo "Stopping network throttle..."
-
- # Flush anchor rules
- sudo pfctl -a "$PF_ANCHOR" -F all 2>/dev/null || true
-
- # Delete dummynet pipe
- sudo dnctl pipe $PIPE_NUM delete 2>/dev/null || true
-
- # Restore original PF config
- if [ -f "$PF_BACKUP" ]; then
- sudo cp "$PF_BACKUP" "$PF_CONF"
- sudo pfctl -f "$PF_CONF" 2>/dev/null || true
- sudo rm "$PF_BACKUP"
- fi
-
- # Restore PF enabled/disabled state (only disable if we enabled it)
- if [ "$PF_WAS_ENABLED" = "no" ]; then
- sudo pfctl -d 2>/dev/null || true
- fi
-
- echo "Throttling disabled."
- exit "$exit_code"
-}
-
-# Register cleanup for all exit paths
-trap cleanup EXIT
-
-echo "Starting network throttle..."
-echo " Bandwidth: $BANDWIDTH"
-echo " Delay: $DELAY"
-echo " Queue: $QUEUE packets"
-
-# Capture PF state before any changes
-capture_pf_state
-
-# Back up pf.conf
-sudo cp "$PF_CONF" "$PF_BACKUP"
-
-# Modify pf.conf: remove lo0 skip and add our anchor point
-{
- grep -v "set skip on lo0" "$PF_BACKUP"
- echo "dummynet-anchor \"$PF_ANCHOR\""
- echo "anchor \"$PF_ANCHOR\""
-} | sudo tee "$PF_CONF" > /dev/null
-
-# Configure dummynet pipe
-sudo dnctl pipe $PIPE_NUM config bw "$BANDWIDTH" delay "$DELAY" queue "$QUEUE"
-
-# Load modified pf config
-sudo pfctl -f "$PF_CONF" 2>/dev/null
-
-# Enable PF if it wasn't already enabled
-if [ "$PF_WAS_ENABLED" = "no" ]; then
- sudo pfctl -E 2>/dev/null || true
-fi
-
-# Add throttling rules for outbound UDP only
-echo "dummynet out proto udp all pipe $PIPE_NUM" | sudo pfctl -a "$PF_ANCHOR" -f -
-
-echo "Throttling enabled. Press Ctrl+C to stop."
-echo ""
-
-# Wait forever until interrupted
-while true; do
- sleep 1
-done
diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts
index 194ccb4e6c..7679bcf50a 100644
--- a/doc/.vitepress/config.ts
+++ b/doc/.vitepress/config.ts
@@ -5,64 +5,233 @@ export default defineConfig({
description: "Real-time latency at massive scale",
base: "/",
- head: [["link", { rel: "icon", href: "/icon.svg", type: "image/svg+xml" }]],
+ head: [
+ ["link", { rel: "icon", href: "/favicon.svg", type: "image/svg+xml" }],
+ ["meta", { property: "og:type", content: "website" }],
+ ["meta", { property: "og:title", content: "Media over QUIC" }],
+ [
+ "meta",
+ {
+ property: "og:description",
+ content: "Real-time latency at massive scale",
+ },
+ ],
+ ["meta", { property: "og:image", content: "https://doc.moq.dev/icon.png" }],
+ ["meta", { property: "og:image:width", content: "163" }],
+ ["meta", { property: "og:image:height", content: "150" }],
+ ["meta", { property: "og:url", content: "https://doc.moq.dev" }],
+ ["meta", { property: "og:site_name", content: "Media over QUIC" }],
+ ["meta", { name: "twitter:card", content: "summary_large_image" }],
+ ["meta", { name: "twitter:title", content: "Media over QUIC" }],
+ [
+ "meta",
+ {
+ name: "twitter:description",
+ content: "Real-time latency at massive scale",
+ },
+ ],
+ ["meta", { name: "twitter:image", content: "https://doc.moq.dev/icon.png" }],
+ ["meta", { name: "theme-color", content: "#0f172a" }],
+ ],
appearance: "force-dark",
themeConfig: {
- logo: "/icon.svg",
+ logo: "/favicon.svg",
nav: [
{ text: "Setup", link: "/setup/" },
- { text: "Concepts", link: "/concepts/" },
- { text: "API", link: "/api/" },
+ { text: "Concepts", link: "/concept/" },
+ { text: "Apps", link: "/bin/" },
+ {
+ text: "Libraries",
+ link: "/lib/",
+ items: [
+ { text: "Rust", link: "/lib/rs/" },
+ { text: "TypeScript", link: "/lib/js/" },
+ { text: "C", link: "/lib/c/" },
+ { text: "Python", link: "/lib/py/" },
+ { text: "Kotlin", link: "/lib/kt/" },
+ { text: "Swift", link: "/lib/swift/" },
+ { text: "Go", link: "/lib/go/" },
+ ],
+ },
],
sidebar: {
"/setup/": [
{
text: "Setup",
+ link: "/setup/",
+ items: [
+ { text: "Development", link: "/setup/dev" },
+ { text: "Production", link: "/setup/prod" },
+ { text: "Linux Packages", link: "/setup/linux" },
+ ],
+ },
+ {
+ text: "Demos",
items: [
- { text: "Quick Start", link: "/setup/" },
- { text: "Development", link: "/setup/development" },
- { text: "Production", link: "/setup/production" },
+ { text: "Web", link: "/setup/demo/web" },
+ { text: "MoQ Boy", link: "/setup/demo/boy" },
],
},
],
- "/concepts/": [
+ "/concept/": [
{
text: "Concepts",
+ link: "/concept/",
items: [
- { text: "Layers", link: "/concepts/" },
- { text: "Latency", link: "/concepts/latency" },
- { text: "Standards", link: "/concepts/standards" },
+ {
+ text: "Layers",
+ link: "/concept/layer/",
+ items: [
+ { text: "quic", link: "/concept/layer/quic" },
+ { text: "web-transport", link: "/concept/layer/web-transport" },
+ { text: "web-socket", link: "/concept/layer/web-socket" },
+ { text: "moq-lite", link: "/concept/layer/moq-lite" },
+ { text: "hang", link: "/concept/layer/hang" },
+ ],
+ },
+ {
+ text: "Standards",
+ link: "/concept/standard/",
+ items: [
+ { text: "MoqTransport", link: "/concept/standard/moq-transport" },
+ { text: "MSF", link: "/concept/standard/msf" },
+ { text: "LOC", link: "/concept/standard/loc" },
+ { text: "Interop", link: "/concept/standard/interop" },
+ ],
+ },
+ {
+ text: "Use Cases",
+ link: "/concept/use-case/",
+ items: [
+ { text: "Contribution", link: "/concept/use-case/contribution" },
+ { text: "Distribution", link: "/concept/use-case/distribution" },
+ { text: "Conferencing", link: "/concept/use-case/conferencing" },
+ { text: "AI", link: "/concept/use-case/ai" },
+ { text: "Other", link: "/concept/use-case/other" },
+ ],
+ },
],
},
],
- "/rust/": [
+ "/bin/": [
{
- text: "Rust Libraries",
+ text: "Applications",
+ link: "/bin/",
items: [
- { text: "Overview", link: "/rust/" },
- { text: "moq-lite", link: "/rust/moq-lite" },
- { text: "hang", link: "/rust/hang" },
- { text: "moq-relay", link: "/rust/moq-relay" },
- { text: "Examples", link: "/rust/examples" },
+ {
+ text: "Relay",
+ link: "/bin/relay/",
+ items: [
+ { text: "Configuration", link: "/bin/relay/config" },
+ { text: "Authentication", link: "/bin/relay/auth" },
+ { text: "Clustering", link: "/bin/relay/cluster" },
+ { text: "HTTP", link: "/bin/relay/http" },
+ { text: "Production", link: "/bin/relay/prod" },
+ ],
+ },
+ { text: "CLI", link: "/bin/cli" },
+ { text: "OBS", link: "/bin/obs" },
+ { text: "GStreamer", link: "/bin/gstreamer" },
+ { text: "Web", link: "/bin/web" },
],
},
],
- "/typescript/": [
+ "/lib/": [
{
- text: "TypeScript Libraries",
+ text: "Libraries",
+ link: "/lib/",
items: [
- { text: "Overview", link: "/typescript/" },
- { text: "@moq/lite", link: "/typescript/lite" },
- { text: "@moq/hang", link: "/typescript/hang" },
- { text: "Web Components", link: "/typescript/web-components" },
- { text: "Examples", link: "/typescript/examples" },
+ {
+ text: "Rust",
+ link: "/lib/rs/",
+ items: [
+ {
+ text: "Environments",
+ link: "/lib/rs/env/",
+ items: [
+ { text: "Native", link: "/lib/rs/env/native" },
+ { text: "WASM", link: "/lib/rs/env/wasm" },
+ ],
+ },
+ {
+ text: "Crates",
+ link: "/lib/rs/crate",
+ items: [
+ { text: "moq-net", link: "/lib/rs/crate/moq-net" },
+ { text: "moq-native", link: "/lib/rs/crate/moq-native" },
+ { text: "moq-token", link: "/lib/rs/crate/moq-token" },
+ { text: "hang", link: "/lib/rs/crate/hang" },
+ { text: "web-transport", link: "/lib/rs/crate/web-transport" },
+ { text: "libmoq", link: "/lib/rs/crate/libmoq" },
+ ],
+ },
+ ],
+ },
+ {
+ text: "TypeScript",
+ link: "/lib/js/",
+ items: [
+ {
+ text: "Environments",
+ link: "/lib/js/env/",
+ items: [
+ { text: "Web", link: "/lib/js/env/web" },
+ { text: "Native", link: "/lib/js/env/native" },
+ ],
+ },
+ {
+ text: "Packages",
+ link: "/lib/js/@moq",
+ items: [
+ { text: "@moq/net", link: "/lib/js/@moq/net" },
+ {
+ text: "@moq/hang",
+ link: "/lib/js/@moq/hang/",
+ items: [
+ { text: "Watch", link: "/lib/js/@moq/hang/watch" },
+ { text: "Publish", link: "/lib/js/@moq/hang/publish" },
+ ],
+ },
+ { text: "@moq/watch", link: "/lib/js/@moq/watch" },
+ { text: "@moq/publish", link: "/lib/js/@moq/publish" },
+ { text: "@moq/token", link: "/lib/js/@moq/token" },
+ { text: "@moq/signals", link: "/lib/js/@moq/signals" },
+ ],
+ },
+ ],
+ },
+ {
+ text: "C",
+ link: "/lib/c/",
+ items: [{ text: "libmoq", link: "/lib/rs/crate/libmoq" }],
+ },
+ {
+ text: "Python",
+ link: "/lib/py/",
+ items: [{ text: "moq-rs", link: "/lib/py/moq-rs" }],
+ },
+ {
+ text: "Kotlin",
+ link: "/lib/kt/",
+ items: [{ text: "dev.moq:moq", link: "/lib/kt/moq" }],
+ },
+ {
+ text: "Swift",
+ link: "/lib/swift/",
+ items: [{ text: "Moq", link: "/lib/swift/moq" }],
+ },
+ {
+ text: "Go",
+ link: "/lib/go/",
+ items: [{ text: "moq", link: "/lib/go/moq" }],
+ },
],
},
],
@@ -74,7 +243,7 @@ export default defineConfig({
],
editLink: {
- pattern: "https://github.com/moq-dev/moq/edit/main/docs/:path",
+ pattern: "https://github.com/moq-dev/moq/edit/main/doc/:path",
text: "Edit this page on GitHub",
},
@@ -88,7 +257,7 @@ export default defineConfig({
footer: {
message: "Licensed under MIT or Apache-2.0",
- copyright: "Copyright © 2025-present MoQ Contributors",
+ copyright: "Copyright © 2026-present moq.dev",
},
},
@@ -97,6 +266,8 @@ export default defineConfig({
lineNumbers: true,
},
- // TODO: Remove this
- ignoreDeadLinks: true,
+ ignoreDeadLinks: [
+ // Localhost URLs are intentional for development
+ "http://localhost:5173",
+ ],
});
diff --git a/doc/.vitepress/theme/custom.css b/doc/.vitepress/theme/custom.css
index 72e86976c6..04bab82fb4 100644
--- a/doc/.vitepress/theme/custom.css
+++ b/doc/.vitepress/theme/custom.css
@@ -108,6 +108,22 @@
margin: 0 auto;
}
+/* Tables */
+.vp-doc table {
+ margin: 2.5rem 0;
+}
+
+.vp-doc th {
+ font-weight: 700;
+ color: #ffffff;
+ background-color: #1e293b;
+ border-bottom: 2px solid #22c55e;
+}
+
+.vp-doc tr:nth-child(even) {
+ background-color: rgba(30, 41, 59, 0.5);
+}
+
/* Navbar background */
.VPNav {
background-color: #0f172a;
diff --git a/doc/bin/cli.md b/doc/bin/cli.md
new file mode 100644
index 0000000000..4bb280699a
--- /dev/null
+++ b/doc/bin/cli.md
@@ -0,0 +1,187 @@
+---
+title: FFmpeg / moq-cli
+description: Command-line tools for MoQ media
+---
+
+# FFmpeg / moq-cli
+
+`moq-cli` is a command-line tool for publishing media to MoQ relays. It works with FFmpeg for encoding.
+
+## Installation
+
+### Using Cargo
+
+```bash
+cargo install moq-cli
+```
+
+### Using Nix
+
+```bash
+# Run directly
+nix run github:moq-dev/moq#moq-cli
+
+# Or build and find the binary in ./result/bin/
+nix build github:moq-dev/moq#moq-cli
+```
+
+### Using Docker
+
+```bash
+docker pull moqdev/moq-cli
+docker run -v "$(pwd)/video.mp4:/app/video.mp4:ro" moqdev/moq-cli publish /app/video.mp4 https://relay.example.com/anon/stream
+```
+
+Multi-arch images (`linux/amd64` and `linux/arm64`) are published to [Docker Hub](https://hub.docker.com/r/moqdev/moq-cli).
+
+### From Source
+
+```bash
+git clone https://github.com/moq-dev/moq
+cd moq
+cargo build --release --bin moq-cli
+```
+
+The binary will be in `target/release/moq-cli`.
+
+## Basic Usage
+
+### Publish a Video File
+
+```bash
+moq-cli publish video.mp4 https://relay.example.com/anon/my-stream
+```
+
+### Publish from FFmpeg
+
+Pipe FFmpeg output directly to moq-cli:
+
+```bash
+ffmpeg -i input.mp4 -f mpegts - | moq-cli publish - https://relay.example.com/anon/my-stream
+```
+
+### Publish a Webcam
+
+```bash
+# macOS
+ffmpeg -f avfoundation -i "0:0" -f mpegts - | moq-cli publish - https://relay.example.com/anon/webcam
+
+# Linux
+ffmpeg -f v4l2 -i /dev/video0 -f mpegts - | moq-cli publish - https://relay.example.com/anon/webcam
+```
+
+### Publish Screen
+
+```bash
+# macOS
+ffmpeg -f avfoundation -i "1:" -f mpegts - | moq-cli publish - https://relay.example.com/anon/screen
+
+# Linux (X11)
+ffmpeg -f x11grab -i :0.0 -f mpegts - | moq-cli publish - https://relay.example.com/anon/screen
+```
+
+## Encoding Options
+
+### Custom Video Settings
+
+```bash
+ffmpeg -i input.mp4 \
+ -c:v libx264 -preset ultrafast -tune zerolatency \
+ -b:v 2500k -maxrate 2500k -bufsize 5000k \
+ -c:a aac -b:a 128k \
+ -f mpegts - | moq-cli publish - https://relay.example.com/anon/stream
+```
+
+### Low Latency Settings
+
+```bash
+ffmpeg -i input.mp4 \
+ -c:v libx264 -preset ultrafast -tune zerolatency \
+ -g 30 -keyint_min 30 \
+ -c:a aac \
+ -f mpegts - | moq-cli publish - https://relay.example.com/anon/stream
+```
+
+### H.265/HEVC
+
+```bash
+ffmpeg -i input.mp4 \
+ -c:v libx265 -preset ultrafast \
+ -c:a aac \
+ -f mpegts - | moq-cli publish - https://relay.example.com/anon/stream
+```
+
+## Authentication
+
+Pass a JWT token via the URL:
+
+```bash
+moq-cli publish video.mp4 "https://relay.example.com/room/123?jwt="
+```
+
+See [Authentication](/bin/relay/auth) for token generation.
+
+## Test Videos
+
+The repository includes helper commands for test content:
+
+```bash
+# Publish Big Buck Bunny
+just pub bbb https://relay.example.com/anon
+
+# Publish Tears of Steel
+just pub tos https://relay.example.com/anon
+```
+
+## Clock Synchronization
+
+Publish and subscribe to clock broadcasts for testing:
+
+```bash
+# Publish a clock
+just clock publish https://relay.example.com/anon
+
+# Subscribe to a clock
+just clock subscribe https://relay.example.com/anon
+```
+
+## Debugging
+
+### Verbose Output
+
+```bash
+RUST_LOG=debug moq-cli publish video.mp4 https://relay.example.com/anon/stream
+```
+
+### Check Connection
+
+```bash
+# Verify you can connect to the relay
+curl http://relay.example.com:4443/announced/
+```
+
+## Common Issues
+
+### "Connection refused"
+
+- Ensure the relay is running
+- Check firewall allows UDP traffic
+- Verify the URL is correct
+
+### "Invalid certificate"
+
+- The relay needs a valid TLS certificate
+- For development, use the fingerprint method
+- See [TLS Setup](/bin/relay/#tls-setup)
+
+### "Permission denied"
+
+- Check your JWT token is valid
+- Verify the token allows publishing to that path
+- See [Authentication](/bin/relay/auth)
+
+## Next Steps
+
+- Deploy a [relay server](/bin/relay/)
+- Use [Web Components](/lib/js/env/web) for playback
+- Try the [Rust libraries](/lib/rs/) for custom apps
diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md
new file mode 100644
index 0000000000..bfb6818b07
--- /dev/null
+++ b/doc/bin/gstreamer.md
@@ -0,0 +1,214 @@
+---
+title: GStreamer Plugin
+description: GStreamer plugin for MoQ
+---
+
+# GStreamer Plugin
+
+A GStreamer plugin for publishing and consuming MoQ streams.
+
+::: warning Work in Progress
+This plugin is currently under development, but it works okay.
+:::
+
+## Overview
+
+The GStreamer plugin provides two elements:
+
+- **moqsink** - Publish media to a MoQ relay
+- **moqsrc** - Subscribe to MoQ broadcasts
+
+Both elements support the following properties:
+
+| Property | Type | Description |
+| -------------------- | ------ | ----------------------------------------------------------------- |
+| `url` | string | The relay URL to connect to |
+| `broadcast` | string | The broadcast name |
+| `tls-disable-verify` | bool | Disable TLS certificate validation (rarely needed, default false) |
+
+::: info
+For `http://` URLs, `moq-native` automatically fetches the server's certificate fingerprint from `/certificate.sha256` and verifies TLS against it. You don't need `tls-disable-verify` for local development.
+:::
+
+## Prerequisites
+
+The plugin requires GStreamer development libraries. It is **not** built by default since most users don't have them installed.
+
+If you're using Nix, GStreamer is included in the dev shell automatically. Otherwise, install manually:
+
+- **macOS:** `brew install gstreamer`
+- **Debian/Ubuntu:** `apt install libgstreamer1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad`
+- **Arch:** `pacman -S gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad`
+
+## Quick start with Nix
+
+If you have Nix installed, you don't need to build anything or set any environment variables. The `moq-gst` flake output bundles the plugin with wrappers around `gst-inspect-1.0` / `gst-launch-1.0` that preload moq alongside `gst-plugins-{base,good,bad}`, so the standard tools find `moqsink` / `moqsrc` automatically.
+
+### Inspect the plugin
+
+```bash
+nix shell github:moq-dev/moq#moq-gst --command gst-inspect-1.0 moq
+```
+
+Lists `moqsink` and `moqsrc`. As a one-liner: `nix run github:moq-dev/moq#moq-gst -- moq`.
+
+### Subscribe to the public test broadcast
+
+`cdn.moq.dev/demo` hosts an always-on `bbb.hang` broadcast (looping Big Buck Bunny). Render it to a window:
+
+```bash
+nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -v -e \
+ moqsrc url=https://cdn.moq.dev/demo broadcast=bbb.hang \
+ ! decodebin3 ! videoconvert ! autovideosink
+```
+
+Audio-only variant: swap the tail for `! decodebin3 ! audioconvert ! autoaudiosink`.
+
+### Publish your own broadcast
+
+`cdn.moq.dev/anon` accepts publishers without auth. Pick a name, publish, then subscribe to that same name (in another terminal or from another machine).
+
+```bash
+# Download a pre-fragmented CMAF test file (one time).
+curl -fsSL https://vid.moq.dev/bbb.mp4 -o bbb.mp4
+
+# Terminal 1: loop the file as a broadcast named `.hang`.
+nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -v -e \
+ multifilesrc location=bbb.mp4 loop=true ! parsebin name=parse \
+ parse. ! queue ! identity sync=true ! mux.sink_0 \
+ parse. ! queue ! identity sync=true ! mux.sink_1 \
+ moqsink name=mux url=https://cdn.moq.dev/anon broadcast=.hang
+```
+
+```bash
+# Terminal 2: render it.
+nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -v -e \
+ moqsrc url=https://cdn.moq.dev/anon broadcast=.hang \
+ ! decodebin3 ! videoconvert ! autovideosink
+```
+
+### Local relay
+
+If you'd rather run a relay yourself, the [relay binary](/bin/relay/) is in the same flake:
+
+```bash
+# Terminal 1: start a relay on localhost:4443.
+nix run github:moq-dev/moq#moq-relay -- demo/relay/localhost.toml
+
+# Terminal 2: publish.
+nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -v -e \
+ multifilesrc location=bbb.mp4 loop=true ! parsebin name=parse \
+ parse. ! queue ! identity sync=true ! mux.sink_0 \
+ parse. ! queue ! identity sync=true ! mux.sink_1 \
+ moqsink name=mux url=http://localhost:4443/anon broadcast=bbb.hang
+
+# Terminal 3: subscribe.
+nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -v -e \
+ moqsrc url=http://localhost:4443/anon broadcast=bbb.hang \
+ ! decodebin3 ! videoconvert ! autovideosink
+```
+
+::: tip
+`http://` URLs auto-verify TLS via `/certificate.sha256` fingerprint pinning, so localhost development needs no certificate setup.
+:::
+
+## Building
+
+```bash
+cargo build -p moq-gst
+```
+
+This produces a shared library (cdylib) in `target/debug/`. GStreamer needs to find this plugin via the `GST_PLUGIN_PATH_1_0` environment variable — the `just` commands below handle this automatically.
+
+## Running Locally
+
+Start a [relay server](/bin/relay/) first:
+
+```bash
+just relay
+```
+
+### Publishing
+
+Use the `just` shortcut to publish a test video via GStreamer:
+
+```bash
+# Publish Big Buck Bunny (downloads automatically)
+just pub gst bbb
+
+# Publish to a remote relay
+just pub gst bbb https://cdn.moq.dev/anon
+```
+
+Or run `gst-launch-1.0` directly:
+
+```bash
+# Point GST_PLUGIN_PATH_1_0 at the build output
+export GST_PLUGIN_PATH_1_0="$PWD/target/debug${GST_PLUGIN_PATH_1_0:+:$GST_PLUGIN_PATH_1_0}"
+
+# Publish a fragmented MP4 file
+gst-launch-1.0 -v -e \
+ multifilesrc location=demo/pub/media/bbb.mp4 loop=true ! parsebin name=parse \
+ parse. ! queue ! identity sync=true ! mux.sink_0 \
+ parse. ! queue ! identity sync=true ! mux.sink_1 \
+ moqsink name=mux url="http://localhost:4443/anon" broadcast="bbb"
+```
+
+::: tip
+The input video must be a fragmented MP4 (CMAF). The `just pub download` helper fetches pre-fragmented test videos from `vid.moq.dev`. To fragment your own video:
+
+```bash
+ffmpeg -i input.mp4 -c copy \
+ -f mp4 -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \
+ output.mp4
+```
+
+:::
+
+### Subscribing
+
+```bash
+# Subscribe and render to the screen
+just sub gst bbb
+
+# Subscribe from a remote relay
+just sub gst bbb https://cdn.moq.dev/anon
+```
+
+Or directly:
+
+```bash
+export GST_PLUGIN_PATH_1_0="$PWD/target/debug${GST_PLUGIN_PATH_1_0:+:$GST_PLUGIN_PATH_1_0}"
+
+gst-launch-1.0 -v -e \
+ moqsrc url="http://localhost:4443/anon" broadcast="bbb" \
+ ! decodebin3 ! videoconvert ! autovideosink
+```
+
+## Supported Codecs
+
+### moqsink (publish)
+
+| Media | Codec | GStreamer caps |
+| ----- | ----- | --------------------- |
+| Video | H.264 | `video/x-h264` |
+| Video | H.265 | `video/x-h265` |
+| Video | AV1 | `video/x-av1` |
+| Audio | AAC | `audio/mpeg` (v4) |
+| Audio | Opus | `audio/x-opus` |
+
+### moqsrc (subscribe)
+
+Outputs the same caps based on the catalog, compatible with `decodebin3`.
+
+## Debugging
+
+Enable GStreamer debug output:
+
+```bash
+# GStreamer debug (verbose)
+GST_DEBUG=*:4 just pub gst bbb
+
+# Rust logging
+RUST_LOG=debug just pub gst bbb
+```
diff --git a/doc/bin/index.md b/doc/bin/index.md
new file mode 100644
index 0000000000..90652c1cd3
--- /dev/null
+++ b/doc/bin/index.md
@@ -0,0 +1,45 @@
+---
+title: Applications
+description: Ready-to-use tools built on MoQ
+---
+
+# Applications
+
+These are the applications you can run today.
+Some are servers, some are command-line tools, and some are web apps.
+
+## [moq-relay](/bin/relay/)
+
+The relay server that routes broadcasts between publishers and subscribers.
+This is the heart of any MoQ deployment that relies on fanout.
+Run it yourself, or pay for an external service (ex. Cloudflare).
+
+- [Configuration](/bin/relay/config) - TOML reference and examples
+- [Authentication](/bin/relay/auth) - JWT-based access control
+- [HTTP Endpoints](/bin/relay/http) - Debugging and diagnostics
+- [Clustering](/bin/relay/cluster) - Multi-region deployment
+
+## [moq-cli](/bin/cli)
+
+A CLI for publishing to media streams.
+Another tool does the encoding (ex. ffmpeg), making it easy to pipe any media into MoQ.
+
+```bash
+# Publish your webcam
+ffmpeg -f avfoundation -i "0" -f mp4 - | moq-cli publish https://relay.example.com my-stream
+```
+
+## [OBS Plugin](/bin/obs)
+
+Real-time latency with the familiar OBS interface.
+Supports both publishing and subscribing.
+
+## [GStreamer Plugin](/bin/gstreamer)
+
+Integrate MoQ into GStreamer pipelines for advanced media workflows.
+Supports both publishing and subscribing.
+
+## [Web Demo](/bin/web)
+
+A demo web application showcasing MoQ in the browser.
+Watch streams, publish from your camera, and explore the API.
diff --git a/doc/bin/obs.md b/doc/bin/obs.md
new file mode 100644
index 0000000000..3638a0284c
--- /dev/null
+++ b/doc/bin/obs.md
@@ -0,0 +1,40 @@
+---
+title: OBS Plugin
+description: OBS Studio plugin for MoQ
+---
+
+# OBS Plugin
+
+An OBS Studio plugin for publishing and consuming MoQ streams.
+
+::: warning Work in Progress
+This plugin is currently under development, but works pretty gud.
+:::
+
+## Overview
+
+The OBS plugin allows you to:
+
+- **Publish** directly from OBS to a MoQ relay
+- **Subscribe** to MoQ broadcasts as an OBS source
+
+## Repository
+
+The plugin is maintained in a separate repository: [moq-dev/obs](https://github.com/moq-dev/obs)
+
+## Usage
+
+### Publishing
+
+1. Open OBS Studio
+2. Go to Settings > Stream
+3. Select "MoQ" as the service
+4. Enter your relay URL and path
+5. Click "Start Streaming"
+
+### Subscribing
+
+1. Add a new source
+2. Select "MoQ Source"
+3. Enter the relay URL and broadcast path
+4. The stream will appear in your scene
diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md
new file mode 100644
index 0000000000..3126c73dd1
--- /dev/null
+++ b/doc/bin/relay/auth.md
@@ -0,0 +1,239 @@
+---
+title: Authentication
+description: JWT-based access control for moq-relay
+---
+
+# Authentication
+
+moq-relay uses JWT (JSON Web Tokens) for authentication and authorization. Tokens control who can publish or subscribe to which paths.
+
+## Overview
+
+There are two authentication modes:
+
+### Single Key (`--auth-key`)
+
+A single JWK file used to verify all tokens. No `kid` header is required in JWTs. Good for development and simple deployments.
+
+### Key Directory (`--auth-key-dir`)
+
+For production use with key rotation. Keys are resolved on demand by extracting the `kid` from the JWT header and fetching the corresponding key file.
+
+1. Generate signing keys (a random key ID is assigned automatically)
+2. Store each key as `{kid}.jwk` in a directory or serve via HTTP
+3. Configure the relay with the key directory or URL
+4. Issue tokens to clients with their allowed paths
+5. Clients connect with `?jwt=` query parameter
+
+## Quick Start
+
+### Generate a Key
+
+Using the Rust CLI:
+
+```bash
+# Symmetric key (simpler, key must stay secret)
+moq-token-cli generate --out my-key.jwk
+
+# Save to a directory as {kid}.jwk
+moq-token-cli generate --out-dir ./keys/
+
+# Asymmetric key (private signs, public verifies)
+moq-token-cli generate --algorithm ES256 --out private.jwk --public public.jwk
+
+# Asymmetric key, both saved to directories as {kid}.jwk
+moq-token-cli generate --algorithm ES256 --out-dir ./private/ --public-dir ./keys/
+```
+
+A random key ID is generated if `--id` is not specified.
+
+### Configure the Relay
+
+Single key (simplest):
+
+```toml
+[auth]
+key = "my-key.jwk"
+```
+
+Key directory (for key rotation):
+
+```toml
+[auth]
+# Point to the public keys directory (from --public-dir).
+# For asymmetric algorithms, the relay only needs public keys to verify tokens.
+key_dir = "/etc/moq/keys/"
+```
+
+Remote key server:
+
+```toml
+[auth]
+key_dir = "https://api.example.com/keys"
+```
+
+### Issue a Token
+
+```bash
+# Allow publishing to demo/my-stream and subscribing to anything under demo/
+moq-token-cli sign --key my-key.jwk --root demo --publish my-stream --subscribe ""
+```
+
+The client connects with the token. The connection path can be the root or any parent:
+
+```text
+# Connect at the token's root
+https://relay.example.com/demo?jwt=eyJhbGciOiJIUzI1NiIs...
+
+# Connect at the server root (permissions still scoped to demo/)
+https://relay.example.com/?jwt=eyJhbGciOiJIUzI1NiIs...
+```
+
+## Key Resolution
+
+### Single Key Mode (`--auth-key`)
+
+The relay uses the specified key file to verify all incoming JWTs. No `kid` header is required in the token.
+
+### Key Directory Mode (`--auth-key-dir`)
+
+Key files are stored as JSON by default. Legacy base64url-encoded files are also supported for backwards compatibility. Use `--base64` when generating keys if you prefer the base64url format.
+
+When a client connects with a JWT, the relay:
+
+1. Decodes the JWT header to extract the `kid` (key ID)
+2. Looks up the key from the configured source: `{dir}/{kid}.jwk` or `{url}/{kid}.jwk`
+3. Verifies the JWT signature with the resolved key
+4. Checks the token's permissions cover the connection path
+
+Key IDs must contain only alphanumeric characters, hyphens, and underscores.
+
+## Token Claims
+
+The JWT payload contains these claims:
+
+| Claim | Description |
+|-------|-------------|
+| `root` | Base path for publish/subscribe permissions |
+| `pub` | Suffix appended to root for publish permission |
+| `sub` | Suffix appended to root for subscribe permission |
+| `exp` | Expiration time (Unix timestamp) |
+| `iat` | Issued-at time (Unix timestamp) |
+
+### Path Matching
+
+The `root` claim sets a base path. The `pub` and `sub` claims are suffixes:
+
+```text
+Full publish path = root + "/" + pub
+Full subscribe path = root + "/" + sub
+```
+
+An empty suffix (`""`) allows access to anything under the root.
+
+**Examples:**
+
+| root | pub | sub | Can publish | Can subscribe |
+|------|-----|-----|-------------|---------------|
+| `demo` | `my-stream` | `""` | `demo/my-stream` | `demo/*` |
+| `rooms/123` | `alice` | `""` | `rooms/123/alice` | `rooms/123/*` |
+| `""` | `""` | `""` | Everything | Everything |
+
+### Connection Path
+
+The client's connection URL path does **not** need to match the token's `root` exactly. The connection path determines the scope of the session — all publish/subscribe operations are relative to it.
+
+- If the connection path **extends** the root (e.g., token root=`demo`, connect to `/demo/room`), permissions are narrowed to only paths under `/demo/room`.
+- If the connection path is a **parent** of the root (e.g., token root=`demo`, connect to `/`), permissions still apply but are scoped to the token's root. You can only access paths under `demo/`.
+- If the connection path is **unrelated** to the root (e.g., token root=`demo`, connect to `/other`), the connection is rejected.
+
+The connection is also rejected if the resulting permissions are empty (no publish or subscribe paths remain after scoping).
+
+## Supported Algorithms
+
+### Symmetric (HMAC)
+
+The same key signs and verifies. Simpler setup, but the key must be kept secret everywhere it's used.
+
+- `HS256` - HMAC with SHA-256 (default)
+- `HS384` - HMAC with SHA-384
+- `HS512` - HMAC with SHA-512
+
+### Asymmetric (RSA/ECDSA)
+
+Private key signs, public key verifies. The relay only needs the public key, so compromise of the relay doesn't leak signing capability.
+
+- `RS256`, `RS384`, `RS512` - RSA PKCS#1 v1.5
+- `PS256`, `PS384`, `PS512` - RSA PSS
+- `ES256`, `ES384` - ECDSA
+- `EdDSA` - Edwards-curve DSA
+
+## Anonymous Access
+
+The `public` setting allows unauthenticated access to a path prefix:
+
+```toml
+[auth]
+key = "my-key.jwk"
+public = "anon" # Anyone can publish/subscribe to anon/*
+```
+
+Set `public = ""` to make everything public (development only).
+
+## mTLS Peer Authentication
+
+In addition to JWT auth, the relay can authenticate peers via mutual TLS. When
+the server is configured with a trusted root CA, any client that presents a
+certificate chaining to that CA is granted **full publish and subscribe access
+within the connection URL path**. The URL path scopes the grant exactly like a
+JWT's `root` claim, so a peer dialing `/demo` can only publish and subscribe
+under `demo/`. A peer dialing `/` (as cluster nodes do) gets an empty root and
+unscoped, cluster-wide access. The token is also flagged as internal, which only
+selects the stats tier used for billing; it grants no extra permissions.
+
+This is primarily intended for relay-to-relay (clustering) authentication, as a
+simpler alternative to distributing long-lived JWTs.
+
+Client certificate presentation is **optional**: connections without a
+certificate fall through to the normal JWT path unchanged.
+
+```toml
+[tls]
+cert = ["/etc/moq/server.pem"]
+key = ["/etc/moq/server.key"]
+# One or more PEM files containing the CAs trusted to sign peer certificates.
+root = ["/etc/moq/peer-ca.pem"]
+```
+
+The certificate is used only to authenticate the peer: the relay verifies the
+chain against the configured CA and reads nothing else from it. A node
+advertises its own identity by setting `--cluster-mesh` to its
+externally-reachable URL, which it publishes on the cluster origin for other
+peers to discover and dial.
+
+Only the `quinn` QUIC backend supports mTLS; configuring `tls.root` with any
+other backend is a startup error.
+
+## Example Configurations
+
+See the [`demo/relay/`](https://github.com/moq-dev/moq/tree/main/demo/relay) directory for complete working configuration files, including authentication setup:
+
+- **Development** - [`demo/relay/root.toml`](https://github.com/moq-dev/moq/blob/main/demo/relay/root.toml) (single key with anonymous access)
+- **Production** - [`demo/relay/prod.toml`](https://github.com/moq-dev/moq/blob/main/demo/relay/prod.toml) (key and key directory options)
+
+## Library Usage
+
+### Rust
+
+- [`rs/moq-token/examples/basic.rs`](https://github.com/moq-dev/moq/blob/main/rs/moq-token/examples/basic.rs) - Symmetric key generation, signing, and verification
+- [`rs/moq-token/examples/asymmetric.rs`](https://github.com/moq-dev/moq/blob/main/rs/moq-token/examples/asymmetric.rs) - Asymmetric key pair with public key extraction
+
+### TypeScript
+
+See [`js/token/examples/sign-and-verify.ts`](https://github.com/moq-dev/moq/blob/main/js/token/examples/sign-and-verify.ts) for a complete working example of signing and verifying tokens.
+
+## See Also
+
+- [moq-token (Rust)](/lib/rs/crate/moq-token) - Rust library and CLI
+- [@moq/token](/lib/js/@moq/token) - TypeScript library and CLI
+- [Relay Configuration](/bin/relay/config) - Full config reference
diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md
new file mode 100644
index 0000000000..ccaf53530a
--- /dev/null
+++ b/doc/bin/relay/cluster.md
@@ -0,0 +1,99 @@
+---
+title: Clustering
+description: Run multiple moq-relay instances across multiple hosts/regions
+---
+
+# Clustering
+
+Relays can be joined together to proxy announcements and subscriptions between each other. A viewer talks to whichever relay is closest; if their broadcast lives somewhere else in the cluster, the local relay fetches it from a neighbor and caches it.
+
+A broadcast carries a small hop list as it travels. Each relay it passes through adds itself to the list, which is how loops are caught and how the network picks the shortest path when there's more than one. When two paths are the same length, every relay breaks the tie the same way (a hash of the broadcast name and hop list), so the whole cluster converges on one route instead of flapping between equals.
+
+## Topology
+
+Each relay lists the peers it wants to dial in `cluster.connect`. That's it; the topology is whatever you draw with those links.
+
+A simple chain works well when one region is the source and others are caches:
+
+```text
+eu-west <--- us-east <--- us-west
+```
+
+```toml
+# us-east.toml
+[cluster]
+connect = ["eu-west.example.com:4443"]
+
+# us-west.toml
+[cluster]
+connect = ["us-east.example.com:4443"]
+```
+
+A publisher on `eu-west` reaches a viewer on `us-west` through `us-east`. If a second `us-west` viewer subscribes to the same broadcast, `us-east` already has it cached, so only one fetch crosses the Atlantic. A full mesh (every relay dialing every other) would skip the cache entirely and waste an outbound link per pair.
+
+Pick the shape that matches your traffic. Linear chains are great for fanout; small N-way meshes are fine when latency matters more than dedup; mixed shapes work too.
+
+## Auto-discovery
+
+Listing every peer by hand can get tedious in larger clusters. Tell the relay its own URL with `cluster.node`, then enable gossip with `cluster.mesh`; connected peers will discover and dial it back automatically:
+
+```toml
+[cluster]
+connect = ["us-east.example.com:4443"]
+node = "us-west.example.com:4443"
+mesh = true
+```
+
+`node` is this relay's identity (its externally-reachable URL); `mesh` is a boolean that turns gossip on. Each gossiping node creates a broadcast carrying its `node` address, which other nodes pick up. `connect` is optional once gossip is running, but you still need at least one connection somewhere (either you dial a peer or a peer dials you) for the advertisement to flow. Enabling `mesh` without `node` is an error, since there'd be no address to advertise.
+
+When two gossiping nodes discover each other, only one of them dials: the node with the lexicographically-smaller URL is the client, the larger is the server. The session is bidirectional, so a single connection carries announcements both ways and the pair avoids opening two redundant links. This tiebreaker applies only to gossip-discovered peers; an explicit `connect` entry always dials.
+
+A relay with `node` + `mesh` and no `connect` is a passive rendezvous: it sits and waits for inbound connections, then helps everyone else find each other.
+
+## Dynamic peer lists
+
+`cluster.connect` is fixed at startup, so adding or removing a node means editing every affected config and restarting. When you'd rather keep the topology somewhere external and change it without a redeploy, point `cluster.connect_api` at an HTTP(S) endpoint or a local file:
+
+```toml
+[cluster]
+connect_api = "https://api.example.com/cluster/connect"
+node = "us-west.example.com:4443"
+```
+
+The source returns a bare JSON array of peer hostnames:
+
+```json
+["eu-west.example.com:4443", "us-east.example.com:4443"]
+```
+
+The relay reconciles that list against its live dials: new entries are dialed, entries that disappear are dropped. It composes with `connect` (static seeds that are never reconciled away) and `mesh` (gossip). The relay's own `node` value, when set, is sent as a `?node=` query parameter so the endpoint can return the peers for that specific node; for mTLS-gated endpoints the cluster client certificate identifies the caller as well.
+
+- **HTTP(S) URL**: re-checked every 30s, but freshness is delegated to a standard HTTP cache (`http-cache`), so the response's `Cache-Control` controls how often a check turns into a real fetch. While the cached list is still fresh (`max-age`), the re-check is served from cache with no network round-trip; once it's stale the cache issues a conditional GET (`ETag` / `Last-Modified`) and falls back to the last cached body if revalidation fails (stale-if-error). Set a longer `max-age` to reduce load on your endpoint, or `no-cache` to force a conditional GET on every tick. Transient endpoint blips don't churn the dial set.
+- **Local file** (a path or `file://` URL): watched via OS filesystem notifications (inotify / FSEvents / kqueue), with a periodic re-check as a safety net.
+
+If a fetch fails or returns garbage, the relay logs and keeps the last good list rather than tearing the cluster down. This keeps the moq-relay binary generic: all routing decisions (which node connects where) live in whatever service answers the endpoint.
+
+## Authentication
+
+Cluster peers must authenticate to each other:
+
+- **mTLS** (recommended). Set `tls.root` to the CA that signed the cluster certificates. Inbound connections presenting a valid client cert are granted full access; outbound dials use `client.tls.cert` / `client.tls.key`.
+- **JWT**. Each relay reads a token from `cluster.token` and presents it on outbound dials. The token needs broad enough scope to cover whatever paths the cluster carries.
+
+See [Authentication](/bin/relay/auth) for the full setup.
+
+## Migration from older configs
+
+`cluster.root` was removed; setting it errors at startup with a message pointing at the replacement. `cluster.mesh` is now a boolean gossip toggle (it used to take this relay's URL); the URL moved to `cluster.node`. The old `mesh = ""` form still works for backwards compatibility: it enables gossip and is treated as `cluster.node`, with a deprecation warning (or an error if it conflicts with an explicit `cluster.node`).
+
+| Old | New |
+|---|---|
+| `root = "rendezvous:4443"` + `node = "us-east:4443"` | `connect = ["rendezvous:4443"]` + `node = "us-east:4443"` + `mesh = true` |
+| `root = "rendezvous:4443"` only | `node = "rendezvous:4443"` + `mesh = true` (passive rendezvous) |
+| `mesh = "us-east:4443"` | `node = "us-east:4443"` + `mesh = true` |
+
+## Next steps
+
+- Deploy to [Production](/bin/relay/prod)
+- Set up [Authentication](/bin/relay/auth)
+- Learn about [Protocol concepts](/concept/layer/)
diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md
new file mode 100644
index 0000000000..e7ed813395
--- /dev/null
+++ b/doc/bin/relay/config.md
@@ -0,0 +1,304 @@
+---
+title: Configuration
+description: TOML configuration reference for moq-relay
+---
+
+# Configuration
+
+moq-relay is configured via a TOML file. Pass the path as the only argument:
+
+```bash
+moq-relay relay.toml
+# or
+moq-relay --config relay.toml
+```
+
+## Minimal Example
+
+```toml
+[server]
+listen = "0.0.0.0:4443"
+
+[server.tls]
+cert = "cert.pem"
+key = "key.pem"
+```
+
+## Full Reference
+
+### \[log]
+
+Logging configuration.
+
+```toml
+[log]
+# Log level: trace, debug, info, warn, error
+# The RUST_LOG environment variable takes precedence
+level = "info"
+```
+
+### \[server]
+
+QUIC/WebTransport server settings.
+
+```toml
+[server]
+# Listen address for QUIC (UDP)
+listen = "0.0.0.0:4443"
+```
+
+### \[server.tls]
+
+TLS configuration for the QUIC endpoint.
+
+```toml
+[server.tls]
+# Option 1: Provide certificate files
+cert = "/path/to/cert.pem" # Certificate chain
+key = "/path/to/key.pem" # Private key
+
+# Option 2: Generate self-signed certificates (development only)
+generate = ["localhost", "127.0.0.1"]
+
+# Optional: root CAs to accept for mTLS peer authentication.
+# Clients that present a cert signed by one of these CAs are granted
+# full access (publish/subscribe/cluster). Intended for relay clustering.
+# Quinn backend only.
+root = ["/path/to/peer-ca.pem"]
+```
+
+For production, use certificates from Let's Encrypt or another CA.
+
+### \[web.http]
+
+HTTP server for debugging endpoints.
+
+```toml
+[web.http]
+# Listen address for HTTP (TCP)
+# Defaults to disabled if not specified
+listen = "0.0.0.0:4443"
+```
+
+See [HTTP Endpoints](/bin/relay/http) for available endpoints.
+
+### \[web.https]
+
+HTTPS/WSS server for TCP fallback.
+
+```toml
+[web.https]
+# Listen address for HTTPS/WSS (TCP)
+listen = "0.0.0.0:443"
+
+# TLS certificates (can be the same as server.tls)
+cert = "cert.pem"
+key = "key.pem"
+```
+
+### \[auth]
+
+Authentication configuration.
+
+```toml
+[auth]
+# Path to the JWT verification key
+# - Symmetric: the shared secret key
+# - Asymmetric: the public key
+key = "root.jwk"
+
+# Path prefix for anonymous access
+# Omit to require authentication everywhere
+public = "anon"
+```
+
+See [Authentication](/bin/relay/auth) for details on token generation.
+
+### \[cluster]
+
+Clustering configuration for multi-relay deployments.
+
+```toml
+[cluster]
+# Peers this relay dials. The topology is whatever you draw with these links.
+connect = ["us-east.example.com:4443"]
+
+# Optional. This relay's own externally-reachable URL (identity). Advertised to
+# peers when gossip is on, and sent to connect_api as ?node=.
+node = "us-west.example.com:4443"
+
+# Optional. Enable gossip discovery: advertise `node` so peers find you
+# automatically. Boolean; requires `node` to be set.
+mesh = true
+
+# Optional. Fetch the peer list from an HTTP(S) endpoint or local file (a JSON
+# array of hostnames) and reconcile it at runtime, no restart needed.
+connect_api = "https://api.example.com/cluster/connect"
+
+# JWT used for outbound cluster dials (alternative to mTLS).
+token = "cluster.jwt"
+```
+
+See [Clustering](/bin/relay/cluster) for topology choices and the trade-off between hand-listed peers and gossip.
+
+### \[client]
+
+Client settings used when connecting to other relays (clustering).
+
+```toml
+[client]
+# Disable TLS verification (development only!)
+tls.disable_verify = true
+
+# Or provide trusted root certificates
+# tls.root = ["/path/to/root.pem"]
+```
+
+### \[stats]
+
+Per-node stats publishing. When enabled, the relay publishes a single
+`/node/` broadcast (or `/node` when `node` is unset)
+carrying JSON snapshots of the broadcasts it's currently serving and of the
+sessions currently connected to it.
+
+```toml
+[stats]
+# Master switch (defaults to false)
+enabled = true
+
+# Top-level path under which stats broadcasts are published (defaults to ".stats")
+prefix = ".stats"
+
+# Seconds between snapshot publishes (defaults to 1)
+interval = 1
+
+# Node identifier appended to the advertised path to disambiguate broadcasts
+# when multiple relays share a cluster origin. May be multi-segment, e.g.
+# "sjc/1" / "sjc/2" for two hosts nested under a shared region key.
+# Single-relay deployments can omit this.
+node = "sjc/1"
+```
+
+Each stats broadcast carries four per-broadcast tracks, one per
+`(tier, role)` pair, plus two session tracks (one per tier):
+
+| Track | What it covers |
+|-----------------------------|---------------------------------------------|
+| `publisher.json` | external (e.g. customer) egress |
+| `subscriber.json` | external ingress |
+| `internal/publisher.json` | internal (e.g. mTLS cluster peer) egress |
+| `internal/subscriber.json` | internal ingress |
+| `sessions.json` | external connected sessions, keyed by root |
+| `internal/sessions.json` | internal connected sessions, keyed by root |
+
+Each per-broadcast frame is a JSON object mapping broadcast path to a
+cumulative counter snapshot. An entry surfaces on any tick where the
+broadcast is live (any open counter still exceeds its `*_closed`
+counterpart, so a subscription could begin at any moment) or its snapshot
+changed since the previous tick. Once every counter equals its `*_closed`
+counterpart no traffic can flow, so the entry is dropped:
+
+```json
+{
+ "demo/bbb": {
+ "announced": 1, "announced_closed": 0,
+ "broadcasts": 1, "broadcasts_closed": 0,
+ "subscriptions": 5, "subscriptions_closed": 2,
+ "bytes": 12345, "frames": 678, "groups": 9
+ },
+ "anon/foo": {
+ "announced": 1, "announced_closed": 0,
+ "broadcasts": 1, "broadcasts_closed": 0,
+ "subscriptions": 2, "subscriptions_closed": 0,
+ "bytes": 234, "frames": 12, "groups": 1
+ }
+}
+```
+
+Field semantics:
+
+- `announced` / `announced_closed`: cumulative count of every broadcast
+ announce/unannounce event on this `(tier, role)` slot, regardless of
+ whether any subscription happened. Use this for "all known broadcasts".
+- `broadcasts` / `broadcasts_closed`: per-(broadcast, session)
+ subscription sentinel. The first active subscription a peer session
+ opens for a broadcast bumps `broadcasts`; the last one it closes bumps
+ `broadcasts_closed`. Summed across sessions, `broadcasts -
+ broadcasts_closed` is the number of distinct sessions currently
+ subscribed to the broadcast (i.e. viewers on the egress side), which is
+ typically what billing and UI want.
+- `subscriptions` / `subscriptions_closed`: cumulative count of
+ track-level subscription guards opened and dropped.
+- `bytes` / `frames` / `groups`: cumulative payload counters from the
+ session loops (both the `moq-lite` and IETF `moq-transport` paths).
+
+The session tracks (`sessions.json`, `internal/sessions.json`) instead map
+each auth root to a `{ sessions, sessions_closed }` snapshot. `sessions`
+bumps when a session authenticated under that root connects and
+`sessions_closed` when it disconnects, so `sessions - sessions_closed` is
+the number of sessions currently connected under the root. This counts
+presence regardless of whether any data flows, so a client connected to
+e.g. `/acme` is billable even while idle. A root entry is emitted while live
+or on the tick it changed, then dropped once no session under it remains:
+
+```json
+{
+ "acme": { "sessions": 3, "sessions_closed": 1 },
+ "globex": { "sessions": 1, "sessions_closed": 0 }
+}
+```
+
+Tier, role, and node are implied by the track and broadcast paths, so
+they aren't repeated inside the frame. Counters are cumulative and
+strictly monotonic; a counter going *backwards* across successive
+snapshots means the underlying entry was garbage-collected and
+re-created (relay restart or a long idle gap). Downstream consumers
+should treat decreases as a fresh session segment and sum across resets
+when computing lifetime totals.
+
+Each snapshot reads `*_closed` atomics before their open counterparts,
+which guarantees the emitted snapshot never shows `closed > open` even
+under concurrent bumps (it can momentarily show an inflated *open* count,
+which is logically valid).
+
+Frames for any one `(tier, role)` are skipped when the JSON is
+byte-identical to the last emitted frame; new subscribers still pick up
+a baseline immediately via track-latest semantics.
+
+Every flag also accepts an equivalent CLI argument (`--stats-enabled`,
+`--stats-prefix`, `--stats-interval`, `--stats-node`) and environment
+variable (`MOQ_STATS_ENABLED`, `MOQ_STATS_PREFIX`, `MOQ_STATS_INTERVAL`,
+`MOQ_STATS_NODE`).
+
+### \[iroh]
+
+Experimental P2P support via iroh.
+
+```toml
+[iroh]
+# Enable iroh for P2P connections
+enabled = false
+
+# Path to persist the iroh secret key
+secret = "./relay-iroh-secret.key"
+```
+
+## Example Configurations
+
+See the [`demo/relay/`](https://github.com/moq-dev/moq/tree/main/demo/relay) directory for working configuration files:
+
+- **Development** - [`demo/relay/root.toml`](https://github.com/moq-dev/moq/blob/main/demo/relay/root.toml)
+- **Production** - [`demo/relay/prod.toml`](https://github.com/moq-dev/moq/blob/main/demo/relay/prod.toml)
+- **Cluster Leaf Node** - [`demo/relay/leaf0.toml`](https://github.com/moq-dev/moq/blob/main/demo/relay/leaf0.toml)
+
+## Environment Variables
+
+- `RUST_LOG` - Override the log level (e.g., `RUST_LOG=debug`)
+- `MOQ_IROH_SECRET` - Set the iroh secret key directly
+
+## See Also
+
+- [Authentication](/bin/relay/auth) - JWT setup
+- [HTTP Endpoints](/bin/relay/http) - Debug endpoints
+- [Clustering](/bin/relay/cluster) - Multi-relay deployments
+- [Production Deployment](/setup/prod) - Production checklist
diff --git a/doc/bin/relay/http.md b/doc/bin/relay/http.md
new file mode 100644
index 0000000000..11cbdb6a85
--- /dev/null
+++ b/doc/bin/relay/http.md
@@ -0,0 +1,84 @@
+---
+title: HTTP
+description: HTTP endpoints exposed by moq-relay
+---
+
+# HTTP Endpoints
+
+moq-relay exposes HTTP/HTTPS endpoints via TCP too.
+These were initially added for debugging but are useful for many things, such as fetching old content.
+
+## Configuration
+
+The relay supports both HTTP and HTTPS, configured independently:
+
+```toml
+[web.http]
+# Listen for unencrypted HTTP connections on TCP
+listen = "0.0.0.0:80"
+
+[web.https]
+# Listen for encrypted HTTPS connections on TCP
+listen = "0.0.0.0:443"
+cert = "cert.pem"
+key = "key.pem"
+```
+
+::: warning
+HTTP is unencrypted, which means any [authentication tokens](/bin/relay/auth) will be sent in plaintext.
+It's recommended to only use HTTPS in production.
+:::
+
+## Notable Endpoints
+
+### GET /announced/\*prefix
+
+Lists all announced broadcasts matching the given prefix.
+
+```bash
+# All broadcasts
+curl http://localhost:4443/announced/
+
+# Broadcasts under "demo/"
+curl http://localhost:4443/announced/demo
+
+# Specific broadcast
+curl http://localhost:4443/announced/demo/my-stream
+```
+
+### GET /fetch/\*path
+
+Fetches a specific group from a track, by default the latest group.
+Useful for quick debugging without setting up a full subscriber, or for fetching old content.
+
+The path is `//`, where the last segment is the track name and everything before it is the broadcast path.
+
+```bash
+# Get latest catalog from broadcast "demo/my-stream"
+curl http://localhost:4443/fetch/demo/my-stream/catalog.json
+
+# Get a specific video group from broadcast "demo/my-stream"
+curl http://localhost:4443/fetch/demo/my-stream/video?group=42
+```
+
+::: tip
+Use HTTP fetch for catch-up and historical data.
+Use MoQ subscriptions for the live edge.
+The two complement each other — HTTP is request/response, MoQ is pub/sub.
+:::
+
+### GET /certificate.sha256
+
+Returns the SHA-256 fingerprint of the TLS certificate.
+This is only useful for local development with self-signed certificates.
+
+```bash
+curl http://localhost:4443/certificate.sha256
+# f4:a3:b2:... (hex-encoded fingerprint)
+```
+
+## See Also
+
+- [Relay Configuration](/bin/relay/config) - Full config reference
+- [Clustering](/bin/relay/cluster) - Multi-relay deployments
+- [hang format](/concept/layer/hang) - Groups, keyframes, and container details
diff --git a/doc/bin/relay/index.md b/doc/bin/relay/index.md
new file mode 100644
index 0000000000..11ab4a6433
--- /dev/null
+++ b/doc/bin/relay/index.md
@@ -0,0 +1,193 @@
+---
+title: moq-relay
+description: A server that connects MoQ publishers and subscribers.
+---
+
+# moq-relay
+
+A server that routes broadcasts between publishers and subscribers, performing caching, deduplication, and fan-out.
+
+## Overview
+
+`moq-relay` is designed to run in datacenters, relaying media across multiple hops to improve quality of service and enable massive scale.
+
+**Features:**
+
+- Fan-out to multiple subscribers
+- Caching and deduplication
+- Cross-region clustering
+- JWT-based authentication
+- HTTP debugging endpoints
+
+## Installation
+
+### From Source
+
+```bash
+git clone https://github.com/moq-dev/moq
+cd moq
+cargo build --release --bin moq-relay
+```
+
+The binary will be in `target/release/moq-relay`.
+
+### Using Cargo
+
+```bash
+cargo install moq-relay
+```
+
+### Using Nix
+
+```bash
+# Run directly
+nix run github:moq-dev/moq#moq-relay
+
+# Or build and find the binary in ./result/bin/
+nix build github:moq-dev/moq#moq-relay
+```
+
+### Using Docker
+
+```bash
+docker pull moqdev/moq-relay
+docker run -p 4443:4443/udp -v "$(pwd)/relay.toml:/app/relay.toml:ro" moqdev/moq-relay -- --config /app/relay.toml
+```
+
+Multi-arch images (`linux/amd64` and `linux/arm64`) are published to [Docker Hub](https://hub.docker.com/r/moqdev/moq-relay).
+
+## Configuration
+
+Create a `relay.toml` configuration file:
+
+```toml
+[server]
+bind = "[::]:4443" # Listen on all interfaces, port 4443
+
+[tls]
+cert = "/path/to/cert.pem" # TLS certificate
+key = "/path/to/key.pem" # TLS private key
+
+[auth]
+public = "anon" # Allow anonymous access to anon/**
+key = "root.jwk" # JWT key for authenticated paths
+```
+
+See [localhost.toml](https://github.com/moq-dev/moq/blob/main/demo/relay/localhost.toml) for a complete example.
+
+## Running
+
+```bash
+moq-relay --config relay.toml
+```
+
+Or with the config path as the only argument:
+
+```bash
+moq-relay relay.toml
+```
+
+## HTTP Endpoints
+
+The relay exposes HTTP/HTTPS endpoints for debugging, health checks, and late-join. See [HTTP](/bin/relay/http) for details.
+
+## TLS Setup
+
+The relay requires TLS certificates. Use [Let's Encrypt](https://letsencrypt.org/):
+
+```bash
+# Install certbot
+sudo apt install certbot # Ubuntu/Debian
+brew install certbot # macOS
+
+# Generate certificate
+sudo certbot certonly --standalone -d relay.example.com
+```
+
+Update `relay.toml`:
+
+```toml
+[tls]
+cert = "/etc/letsencrypt/live/relay.example.com/fullchain.pem"
+key = "/etc/letsencrypt/live/relay.example.com/privkey.pem"
+```
+
+## Monitoring
+
+### Logging
+
+Set log level via environment variable:
+
+```bash
+RUST_LOG=info moq-relay relay.toml
+RUST_LOG=debug moq-relay relay.toml
+RUST_LOG=moq_relay=trace moq-relay relay.toml
+```
+
+### Metrics
+
+Metrics (Prometheus format) are planned but not yet implemented.
+
+Current visibility:
+
+- Check logs for connection count
+- Use [HTTP endpoints](/bin/relay/http) for track inspection
+- Monitor system resources (CPU, memory, bandwidth)
+
+## Performance
+
+### Current Status
+
+- **Single-threaded** - Quinn uses one UDP receive thread
+- **In-memory caching** - Recent groups stored in RAM
+- **Mesh clustering** - All relays connect to all others
+
+### Scaling
+
+- **Vertical** - Fast CPU matters more than core count
+- **Horizontal** - Deploy multiple relays in different regions
+- **Cluster size** - 3-5 nodes optimal with current implementation
+
+### Future Improvements
+
+- Multi-threaded UDP processing
+- Tree-based clustering topology
+- Improved memory management
+- Metrics and observability
+
+## Troubleshooting
+
+### Port Already in Use
+
+```bash
+# Check what's using port 4443
+lsof -i :4443
+
+# Kill the process or use a different port
+```
+
+### Certificate Errors
+
+Ensure:
+
+- Certificate is valid and not expired
+- Certificate matches domain name
+- Private key has correct permissions
+- Certificate includes full chain
+
+### Connection Timeouts
+
+Check:
+
+- UDP port is open in firewall
+- Cloud provider allows UDP traffic
+- TLS certificate is valid
+- Relay is actually running
+
+## Next Steps
+
+- Set up [Authentication](/bin/relay/auth)
+- Configure [Clustering](/bin/relay/cluster)
+- Deploy to [Production](/bin/relay/prod)
+- Use [moq-net](/lib/rs/crate/moq-net) client library
+- Build media apps with [hang](/lib/rs/crate/hang)
diff --git a/doc/bin/relay/prod.md b/doc/bin/relay/prod.md
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/doc/bin/web.md b/doc/bin/web.md
new file mode 100644
index 0000000000..9e3093deaa
--- /dev/null
+++ b/doc/bin/web.md
@@ -0,0 +1,32 @@
+---
+title: Web Demo
+description: Browser-based demo application for MoQ
+---
+
+# Web Demo
+
+The web demo application showcases MoQ's browser capabilities, allowing you to publish and watch live streams directly from a web browser.
+
+## Overview
+
+Located at `demo/web/`, the web demo provides:
+
+- **Publishing** — Capture camera, microphone, or screen and publish via MoQ
+- **Watching** — Subscribe to and render live broadcasts with low latency
+- **Web Components** — Uses `` and `` custom elements
+
+## Running Locally
+
+```bash
+cd demo/web
+bun install
+bun run dev
+```
+
+Then open in a browser with WebTransport support (Chrome 97+, Edge 97+).
+
+## Related
+
+- [@moq/watch](/lib/js/@moq/watch) — Watch/subscribe package
+- [@moq/publish](/lib/js/@moq/publish) — Publish package
+- [Relay setup](/bin/relay/) — Server to connect to
diff --git a/doc/concept/index.md b/doc/concept/index.md
new file mode 100644
index 0000000000..31243ef333
--- /dev/null
+++ b/doc/concept/index.md
@@ -0,0 +1,30 @@
+---
+title: Concepts
+description: Understanding MoQ's fundamental concepts
+---
+
+# Concepts
+
+Welcome to my favorite section.
+MoQ has been a multi-year journey to solve some very real problems in the industry and now it's time to flex the design.
+
+## Layers
+
+MoQ is carefully broken into layers to make it simple, composable, and customizable.
+We don't want you to hit a brick wall if you deviate from the standard path (*ahem* WebRTC).
+
+See [Layers](/concept/layer/) for more information.
+
+## Standards
+
+MoQ is built on open standards and protocol specifications.
+We're in this together, even if we disagree on some details.
+
+See [Standards](/concept/standard/) for more information.
+
+## Use Cases
+
+MoQ is designed to be used in a variety of use-cases.
+Distribution, contribution, conferencing, and more.
+
+See [Use Cases](/concept/use-case/) for more information.
diff --git a/doc/concept/layer/hang.md b/doc/concept/layer/hang.md
new file mode 100644
index 0000000000..c193638e38
--- /dev/null
+++ b/doc/concept/layer/hang.md
@@ -0,0 +1,133 @@
+---
+title: Hang
+description: A simple, WebCodecs-based media format utilizing MoQ.
+---
+
+# hang
+
+A simple, WebCodecs-based media format utilizing MoQ. See the [specification](https://datatracker.ietf.org/doc/draft-lcurley-moq-hang/) for wire-level details.
+
+## Catalog
+
+`catalog.json` is a special track that contains a JSON description of available tracks.
+This is how the viewer decides what it can decode and wants to receive.
+The catalog track is live updated as media tracks are added, removed, or changed.
+
+Each media track is described using the [WebCodecs specification](https://www.w3.org/TR/webcodecs/) and we plan to support every codec in the [WebCodecs registry](https://w3c.github.io/webcodecs/codec_registry.html).
+
+### Example
+
+Here is Big Buck Bunny's `catalog.json` as of 2026-02-02:
+
+```json
+{
+ "video": {
+ "renditions": {
+ "video0": {
+ "codec": "avc1.64001f",
+ "description": "0164001fffe100196764001fac2484014016ec0440000003004000000c23c60c9201000568ee32c8b0",
+ "codedWidth": 1280,
+ "codedHeight": 720,
+ "container": "legacy"
+ }
+ }
+ },
+ "audio": {
+ "renditions": {
+ "audio1": {
+ "codec": "mp4a.40.2",
+ "sampleRate": 44100,
+ "numberOfChannels": 2,
+ "bitrate": 283637,
+ "container": "legacy"
+ }
+ }
+ }
+}
+```
+
+### Audio
+
+[See the latest schema](https://github.com/moq-dev/moq/blob/main/js/hang/src/catalog/audio.ts).
+
+Audio is split into multiple renditions that should all be the same content, but different quality/codec/language options.
+
+Each rendition is an extension of [AudioDecoderConfig](https://www.w3.org/TR/webcodecs/#audio-decoder-config).
+This is the minimum amount of information required to initialize an audio decoder.
+
+### Video
+
+[See the latest schema](https://github.com/moq-dev/moq/blob/main/js/hang/src/catalog/video.ts).
+
+Video is split into multiple renditions that should all be the same content, but different quality/codec/language options.
+Any information shared between multiple renditions is stored in the root.
+For example, it's not possible to have a different `flip` or `rotation` value for each rendition,
+
+Each rendition is an extension of [VideoDecoderConfig](https://www.w3.org/TR/webcodecs/#video-decoder-config).
+This is the minimum amount of information required to initialize a video decoder.
+
+## Container
+
+The catalog also contains a `container` field for each rendition used to denote the encoding of each track.
+Unfortunately, the raw codec bitstream lacks timestamp information so we need some sort of container.
+
+Containers can support additional features and configuration.
+For example, `CMAF` specifies a timescale instead of hard-coding it to microseconds like `legacy`.
+
+### Legacy
+
+This is a lightweight container with no frills attached.
+It's called "legacy" because it's not extensible nor optimized and will be deprecated in the future.
+
+Each frame consists of:
+
+- A 62-bit (varint-encoded) presentation timestamp in microseconds.
+- The codec payload.
+
+### CMAF
+
+This is a more robust container used by HLS/DASH.
+
+Each frame consists of:
+
+- A `moof` box containing a `tfhd` box and a `tfdt` box.
+- A `mdat` box containing the codec payload.
+
+Unfortunately, fMP4 is not quite designed for real-time streaming and incurs either a latency or size overhead:
+
+- Minimal latency: 1-frame fragments introduce ~100 bytes of overhead per frame.
+- Minimal size (HLS): GoP sized fragments introduce a GoP's worth of latency.
+- Mixed latency/size (LL-HLS): 500ms-sized fragments introduce a 500ms latency, with some additional overhead.
+
+## `description`
+
+The `description` field in audio/video renditions contains codec-specific initialization data based on the [WebCodecs codec registration](https://www.w3.org/TR/webcodecs-codec-registry/).
+
+For example, the `description` field for [H.264](https://www.w3.org/TR/webcodecs-avc-codec-registration/) can be:
+
+- **present**: the `description` is an `avcC` box, containing the SPS/PPS and other information.
+- **absent**: the SPS/PPS NALUs are delivered **inline** before each keyframe.
+
+There's no "right format" and both exist in the wild.
+Inlining the SPS/PPS marginally increases the overhead of each frame, but it means the decoder can be reinitialized (ex. resolution change).
+
+Unfortunately, your decoder should handle both.
+
+## Groups and Keyframes
+
+Each MoQ group aligns with a video Group of Pictures (GoP).
+A new group starts with a keyframe (IDR frame) that can be decoded independently.
+
+This has important implications:
+
+- **Skipping a group means skipping an entire GoP.** The relay can drop old groups without corrupting the decoder state.
+- **Late-join viewers** start at the beginning of a group (the keyframe), since it's not possible to join mid-group.
+- **Audio groups** don't need to align with video groups and can contain any number of frames.
+
+The relay uses group boundaries for partial reliability: if congestion occurs, entire groups are dropped rather than individual frames, keeping the decoder in a consistent state.
+
+## Custom Media Formats
+
+You can make your own media format if you have full control over the publisher and all viewers.
+You would be missing out on existing tools and libraries but it's really not that complicated;
+QUIC and moq-lite do the heavy lifting.
diff --git a/doc/concept/layer/index.md b/doc/concept/layer/index.md
new file mode 100644
index 0000000000..2e823cc46b
--- /dev/null
+++ b/doc/concept/layer/index.md
@@ -0,0 +1,83 @@
+---
+title: Layering
+description: It's like a cake; you choose if you want frosting.
+---
+
+# Layers
+
+The design philosophy of MoQ is to make things simple, composable, and customizable.
+We don't want you to hit a brick wall if you deviate from the standard path (*ahem* WebRTC).
+We also want to benefit from economies of scale (like HTTP), utilizing generic libraries and tools whenever possible.
+
+To accomplish this, MoQ is broken into layers stacked on top of each other.
+It's like a cake; you choose whether you want frosting or not.
+
+## QUIC
+
+[QUIC](/concept/layer/quic) is the core protocol that powers HTTP/3, designed to fix the head-of-line blocking that plagues TCP.
+
+Think of it like:
+
+- **TCP 2.0**: connections with congestion control, flow control, and retransmissions. But now with independent streams and prioritization to avoid head-of-line blocking.
+- **UDP 2.0**: optional reliability and datagrams, allowing stuff to get dropped during congestion.
+
+It's a web standard, available in all major browsers, battle-tested by huge CDNs, and with great open-source implementations from every major tech company.
+
+## WebTransport (optional)
+
+[WebTransport](/concept/layer/web-transport) is a small layer that shares a QUIC connection with HTTP/3.
+
+Basically it's like WebSocket but for QUIC instead of TCP.
+Browsers need it because not using HTTP would be some cardinal sin or something.
+Everybody else can use QUIC directly instead.
+
+## WebSocket (optional)
+
+[WebSocket](/concept/layer/web-socket) is a TCP fallback for when QUIC is blocked (corporate firewalls) or unsupported (Safari).
+
+MoQ clients will automatically race the QUIC and WebSocket connections in parallel, using whatever wins the race.
+It sucks but sometimes *media over QUIC* isn't actually an option.
+
+## MoQ Transport
+
+[moq-lite](/concept/layer/moq-lite) is a forwards-compatible subset of the [MoqTransport](/concept/standard/moq-transport) specification.
+moq-lite clients work with any moq-transport CDN, so you're not locked in.
+
+The goal is a generic pub/sub protocol that can be scaled up via a CDN (see [moq-relay](/bin/relay/)).
+The CDN doesn't know anything about media, it just knows track/group/frame boundaries and what it should do during congestion.
+Think of it like HTTP but for live content.
+
+MoQ is bidirectional, so a **session** can be both a **publisher** and a **subscriber**:
+
+- A publisher produces **broadcasts**, split into one or more **tracks**.
+- A subscriber discovers available broadcasts and can choose to subscribe to tracks.
+
+Each subscription is split into QUIC streams:
+
+- A **group** is a QUIC stream (independent, unordered) that can be closed or reset (congestion).
+- A **frame** is a chunk of bytes with an upfront size, delivered reliably and in order *within a group*.
+
+## Media Format
+
+[hang](/concept/layer/hang) is a simple media format running on top of moq-lite.
+The relay doesn't care about media details, but the end clients need to agree on something: a catalog of tracks, a container for each frame, and codec configuration.
+
+The IETF is working on a [suite of drafts](/concept/standard/msf) but the ideas are similar.
+
+## Application
+
+MoQ tracks are additive.
+You can create new tracks for whatever purpose and it doesn't interfere with the function of a relay or media client.
+Go ahead, create a `controller` track to stream button presses and it will be treated like any other opaque sequence of bytes.
+
+MoQ is implemented in application space, not built into the browser like WebRTC.
+If you don't like something, fork it and ship your own web and native apps.
+
+## More Info
+
+I'd recommend starting with the minimal:
+
+- [moq-lite](/concept/layer/moq-lite)
+- [hang](/concept/layer/hang)
+
+Then read more about the various [IETF standards](/concept/standard/).
diff --git a/doc/concept/layer/moq-lite.md b/doc/concept/layer/moq-lite.md
new file mode 100644
index 0000000000..38424d7260
--- /dev/null
+++ b/doc/concept/layer/moq-lite.md
@@ -0,0 +1,160 @@
+---
+title: MoQ Lite
+description: A simple, forwards-compatible subset of MoQ Transport. Avoids some of the more complex (and dangerous) functionality.
+---
+
+# moq-lite
+
+This website uses [moq-lite](/concept/layer/moq-lite), a subset of the IETF [moq-transport](/concept/standard/moq-transport) draft.
+moq-lite is forwards compatible with moq-transport so it works with any moq-transport CDN (ex. [Cloudflare](https://moq.dev/blog/first-cdn)).
+The principles behind MoQ are fantastic, but standards are **SLOW** and involve too much arguing.
+My goal is to build something simple that you can use *now*, even if it's not a standard *yet*.
+
+See the [specification](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/) for low-level details.
+
+## API
+
+### Terminology
+
+- **Session** - A bidirectional connection between a client and a server.
+- **Origin** - A collection of **broadcasts**, used to scope what is available to a session.
+- **Broadcast** - A named and discoverable collection of **tracks** from a single publisher.
+- **Track** - A series of **groups**, potentially delivered out-of-order until closed/cancelled.
+- **Group** - A series of **frames** delivered in order until closed/cancelled.
+- **Frame** - A chunk of bytes with an upfront size.
+
+**NOTE:** The IETF draft uses some different names.
+THE BIKE SHED MUST BE PAINTED RED.
+
+- `Origin` -> (doesn't exist in moq-transport)
+- `Broadcast` -> `Namespace`
+- `Frame` -> `Object`
+
+### Session Establishment
+
+When a client connects to a server, it sends a list of supported ALPNs.
+The server selects the first supported one to negotiate the protocol/version.
+
+If `h3` is negotiated, then we do *another* ALPN negotiation as part of the WebTransport handshake.
+It's gross but required for web browsers, so we suck it up.
+
+Here's a list of currently supported ALPNs:
+
+- `moql`: moq-lite, the version is negotiated via `SETUP`.
+- `moq-lite-03`: moq-lite draft 3
+- `moq-00`: moq-transport draft 14, the version is negotiated via `SETUP`.
+- `moqt-15`: moq-transport draft 15
+- `moqt-16`: moq-transport draft 16
+- `moqt-17`: moq-transport draft 17
+- etc...
+
+See the Compatibility section below for more details about `moq-transport` support.
+
+Once the QUIC or WebTransport connection is established, there is a minimal MoQ handshake.
+The `SETUP` message is primarily used to negotiate extensions, then you're off to the races!
+
+### Announcements
+
+`moq-lite` optionally supports live discovery of broadcasts.
+
+Depending on the language, there's an `announced(prefix: Path)` method on the session.
+This asks the peer to notify us of any existing broadcasts that match the prefix and any future updates.
+
+This is extremely useful for conference rooms, as you can live discover when participants join and leave.
+It's also useful for individual broadcasts as you can get notifications it comes online or goes offline (no spamming F5).
+The [moq-relay clustering](/bin/relay/cluster) feature actually uses this to discover other nodes in the cluster AND what broadcasts are available on each node.
+
+### Subscriptions
+
+All data transfers are initiated by subscriptions.
+
+The subscriber needs to send a `SUBSCRIBE` message indicating the **broadcast** and **track** they want (both strings).
+There are additional options, such as `priority`, that primarily impact the behavior during congestion.
+See the congestion section below for more details.
+
+If the peer doesn't have the broadcast/track, they will get an error.
+Otherwise, the subscription is active and will stay open until closed by the publisher (possibly with an error).
+
+A track is broken into **groups**, each with an increasing ID.
+Conceptually, these are join points, and new subscriptions will always start at the latest group.
+Groups are delivered independently and potentially out of order, so you should have some logic to reorder or skip during congestion.
+A group is closed when finished or aborted with an error (ex. during congestion).
+
+Each group consists of one or more **frames**.
+Frames within a group are delivered reliably and in order.
+You can and should take advantage of this, for example using delta encoding.
+If frames within a group are actually independent, you should probably split them into individual groups!
+
+### Congestion
+
+If it's not obvious by now, a lot of MoQ's behavior is designed to be robust to congestion.
+
+When congestion occurs, something **MUST** get dropped.
+MoQ puts each subscriber (viewer) in control, allowing them to choose how much latency they can tolerate.
+This is how the same protocol can deliver the same content anywhere between 100ms of latency to 30s of latency.
+
+Each Subscription consists of a few properties:
+
+- **Track Priority**: A value between 0 and 255. Tracks with higher priority will be delivered first.
+- **Group Order**: The order in which groups are delivered. Defaults to descending; higher IDs are delivered first.
+- **Group Timeout**: The maximum duration to keep old groups in cache/transit. Defaults to 30 seconds.
+
+By utilizing these properties, you can choose how your application behaves during congestion.
+For example, consider a conference room with Alice and Bob:
+
+| Track | Priority | Order | Timeout |
+|-------|----------|-------|---------|
+| `alice/audio` | 100 | ascending | 500ms |
+| `bob/audio` | 90 | ascending | 500ms |
+| `alice/video` | 50 | descending | 2s |
+| `bob/video` | 40 | descending | 2s |
+
+When combined with a local jitter buffer, this should result in different user experiences based on the network conditions:
+
+- **No Congestion**: Every frame is delivered immediately.
+- **Minor Congestion**: Bob's video might skip a few frames at the tail of each group.
+- **Moderate Congestion**: Bob and Alice's video will skip the tail of each group, but audio will still be delivered.
+- **Heavy Congestion**: Bob and Alice's audio might fall behind, but never more than 500ms. Video is completely dropped.
+
+There's no optimal solution for this, but we think these subscription properties provide a GOOD ENOUGH user experience for most use-cases.
+They're simple to implement and easy enough to understand.
+
+## Compatibility
+
+`moq-lite` is forward compatible with `moq-transport`.
+That means for every moq-lite API, there's a corresponding moq-transport API.
+
+That's good!
+You're not locked into moq-lite and can use moq-transport in the future.
+I can get hit by a bus and you wouldn't shed a tear.
+
+When `moq-transport` wire format is negotiated, we still enforce the moq-lite API.
+If the peer insists on using a moq-transport-only feature, we fake it or worst case, return an error.
+For example, if there's a gap in a group (valid in moq-transport), we drop the tail of the group instead of erroring.
+
+The following table shows the simplified compatibility matrix.
+Note that there are typically 2 clients, a publisher and a subscriber.
+But if a publisher needs a feature, then the subscriber needs it too, so you can lump them together.
+
+| client | relay | supported | notes |
+|---------------|---------------|:---------:|----------------------------------------------------------------------|
+| moq-lite | moq-lite | ✅ | |
+| moq-lite | moq-transport | ✅ | |
+| moq-transport | moq-lite | ⚠️ | No moq-transport-only features. |
+| moq-transport | moq-transport | ⚠️ | Depends on the implementations. |
+
+### Major Differences
+
+- **No Request IDs**: A bidirectional stream for each request to avoid HoLB. (NOTE: likely to be upstreamed into moq-transport)
+- **No Push**: A subscriber must explicitly subscribe to each track.
+- **No FETCH**: Use HTTP for VOD instead of reinventing the wheel.
+- **No Joining Fetch**: Subscriptions start at the latest group, not the latest frame.
+- **No sub-groups**: SVC layers should be separate tracks.
+- **No gaps**: Makes life much easier for the relay and every application.
+- **No object properties**: Encode your metadata into the frame payload.
+- **No pausing**: Unsubscribe if you don't want a track.
+- **No binary names**: Uses UTF-8 strings instead of arrays of byte arrays.
+- **No datagrams**: Maybe one day.
+
+This may seem like a lot of missing features, but in practice you don't need them.
+For example, [MSF](/concept/standard/msf) doesn't use any of these features so it's fully compatible with moq-lite.
diff --git a/doc/concept/layer/quic.md b/doc/concept/layer/quic.md
new file mode 100644
index 0000000000..07b7e6b0c5
--- /dev/null
+++ b/doc/concept/layer/quic.md
@@ -0,0 +1,117 @@
+---
+title: QUIC
+description: The transport protocol that makes MoQ possible
+---
+
+# QUIC
+
+[RFC9000](https://datatracker.ietf.org/doc/html/rfc9000) - QUIC: A UDP-Based Multiplexed and Secure Transport
+
+QUIC is why MoQ exists.
+It's the protocol that finally grants us the web support needed for real-time streaming.
+
+## History
+
+To explain the purpose of QUIC, it's helpful to understand the history of HTTP.
+Let's take a quick trip through history:
+
+- **HTTP/1**: One request at a time per TCP connection. Want to load 10 images? You either suffer from head-of-line blocking or open 10 connections, each with an expensive TCP/TLS handshake.
+- **HTTP/2**: Multiplexing! Multiple requests over one TCP connection. But wait... TCP still delivers bytes in order, so one lost packet blocks *everything*. We traded one form of head-of-line blocking for another.
+- **HTTP/3**: Built on QUIC with multiplexed streams that are truly independent. A lost packet in one stream doesn't block the others.
+
+RTMP and HLS suffer from this same head-of-line blocking problem.
+Old audio/video frames end up blocking new frames from being delivered, driving up latency during congestion.
+
+## Why Not Raw UDP?
+
+"Just use UDP" has been the rallying cry of real-time media for decades.
+That's exactly the route that protocols like WebRTC and SRT have taken, but they incur a high complexity cost.
+Every implementation needs custom encryption, congestion control, flow control, retransmissions, prioritization, NAT traversal, browser support, and more.
+
+Only Google (WebRTC) has managed to navigate these problems and create a solid protocol that works in browsers.
+But despite all of the complexity, it can really only support a single use-case: conferencing.
+
+The point of MoQ is to avoid reinventing the wheel and focus on **media** instead of **networking**.
+QUIC is a fantastic protocol with wide support and the features we need.
+
+## Features
+
+Speaking of features, here's a QUICk summary of the features MoQ relies on.
+
+### Streams
+
+After establishing a QUIC connection, both sides can create streams.
+This is can be done instantly (without overhead) provided the configurable limit has not been reached.
+
+There's two flavors of streams:
+
+- **Bidirectional**: A stream that can be read from and written to.
+- **Unidirectional**: A stream that can only be written to.
+
+Each stream is a reliable sequence of bytes, with any gaps automatically retransmitted.
+A stream can be closed to mark the final size, or it can be reset (by either side) to immediately terminate it.
+
+In MoQ, we use bidirectional streams for control messages and unidirectional streams for subscription data.
+
+Each stream is *mostly* independent of each other, containing its own data and flow control.
+A lost packet on stream A doesn't stall stream B, nor do they have to be retransmitted together.
+Stream A can be closed or reset independently of stream B.
+
+In MoQ, we create a new stream for each video Group of Pictures.
+All frames within a GoP are reliably delivered in order so the decoder will not error.
+But Group A won't block Group B, nor will Track A block Track B.
+
+**NOTE**: Some other implementations use QUIC datagrams instead of streams.
+This can make sense for real-time audio when retransmissions are not needed.
+
+### Reliability
+
+QUIC provides three flavors of reliability:
+
+- **Full Reliability**: A QUIC stream will be retransmitted until every byte arrives. Perfect for mandatory data like the catalog.
+- **Partial Reliability**: A QUIC stream can be immediately RESET with an error code, aborting any forward progress. Perfect for skipping old video frames when you're behind - just reset the stream and move on.
+- **No Reliability**: QUIC datagrams (an extension) can be sent without any queuing or retransmission. Fire and forget when retransmissions are not desired.
+
+MoQ primarily uses partial reliability, reseting streams once the content is no longer desired (max latency).
+However, in order to support multiple different latency targets, we prefer to prioritize streams instead of resetting them.
+
+### Prioritization
+
+The QUIC library is responsible for constructing and sending each UDP packet.
+This means a QUIC library can prioritize streams by deciding which packet to send next.
+
+This is useful in order to:
+
+- Prioritize audio over video
+- Prioritize recent frames over old frames
+
+MoQ uses this extensively.
+When the network is congested, old groups get starved while new groups get through immediately.
+We'll eventually reset old groups once a maximum latency is reached, but it's better to prioritize than to reset.
+
+### Connection Migration
+
+Something we get for free is connection migration.
+QUIC connections can survive IP address changes, such as switching from WiFi to cellular.
+
+This is because QUIC uses connection IDs rather than the traditional `IP:port` tuple.
+It's also the basis for some pretty neat [load balancing](https://datatracker.ietf.org/doc/html/draft-ietf-quic-load-balancers) techniques.
+
+## Browser Support
+
+QUIC is available in browsers via [WebTransport](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API):
+
+- **Chrome/Edge** - Supported since 2021 (97)
+- **Firefox** - Supported since 2023 (114)
+- **Safari** - Supported since 2026 (26.4)
+
+The late arrival of Safari support is a bit of a bummer, because the Safari version is tied to the OS version.
+That means MacOS 26.4 and iOS 26.4 is the minimum version for Safari support.
+We have an (automatic) WebSocket fallback in the meantime.
+
+## Security
+
+TLS 1.3 is required for QUIC.
+
+This can be annoying for local development and private networks.
+There is some performance overhead of course, but the main problem is that you need TLS certificates.
diff --git a/doc/concept/layer/web-socket.md b/doc/concept/layer/web-socket.md
new file mode 100644
index 0000000000..1899a941f8
--- /dev/null
+++ b/doc/concept/layer/web-socket.md
@@ -0,0 +1,123 @@
+---
+title: WebSocket
+description: A TCP fallback for corporate nerds who block QUIC, and Safari users.
+---
+
+# WebSocket
+
+I'm bored of writing so much documentation.
+Check out the next page on [moq-lite](/concept/layer/moq-lite) instead; it's way more interesting.
+
+Here's some AI slop for now:
+
+## AI SLOP
+
+WebSocket is a TCP fallback for when QUIC/WebTransport isn't available.
+This happens more often than you'd think: corporate firewalls love blocking UDP, and Safari didn't support WebTransport until 26.4.
+
+We use a thin polyfill called [web-transport-ws](https://github.com/moq-dev/web-transport/tree/main/rs/web-transport-ws) that emulates the WebTransport API over a WebSocket connection.
+It multiplexes streams using a simple binary framing protocol, so the rest of the stack doesn't need to know or care that it's running over TCP.
+
+### How It Works
+
+When establishing a connection, the client races QUIC/WebTransport against WebSocket in parallel.
+WebSocket wins the race when UDP is blocked or WebTransport isn't supported.
+
+The polyfill converts the URL scheme (`https://` → `wss://`, `http://` → `ws://`) and connects with the `webtransport` subprotocol.
+Once connected, it provides the same API as WebTransport: bidirectional streams, unidirectional streams, and connection management.
+
+Obviously it won't perform as well as QUIC during congestion — everything gets head-of-line blocked over TCP.
+But it's better than not working at all.
+
+### Streams
+
+Stream IDs are encoded as variable-length integers with the lower 2 bits indicating the stream type:
+
+| Bit 0 | Bit 1 | Type |
+|-------|-------|------|
+| 0 | 0 | Client-initiated bidirectional |
+| 1 | 0 | Server-initiated bidirectional |
+| 0 | 1 | Client-initiated unidirectional |
+| 1 | 1 | Server-initiated unidirectional |
+
+This matches the [QUIC stream ID scheme](https://datatracker.ietf.org/doc/html/rfc9000#section-2.1).
+
+### Framing
+
+Each WebSocket binary message contains a single frame.
+The frame type is identified by the first byte, borrowing values from the QUIC spec:
+
+#### `STREAM` (0x08) / `STREAM_FIN` (0x09)
+
+Carries data for a stream. `STREAM_FIN` indicates the final data on the stream.
+
+```text
++--------+-----------+---------+
+| Type | Stream ID | Payload |
+| 1 byte | VarInt | ... |
++--------+-----------+---------+
+```
+
+No length field is needed since WebSocket already provides message boundaries.
+
+#### `RESET_STREAM` (0x04)
+
+Abruptly terminates the sending side of a stream with an error code.
+
+```text
++--------+-----------+------------+
+| Type | Stream ID | Error Code |
+| 1 byte | VarInt | VarInt |
++--------+-----------+------------+
+```
+
+#### `STOP_SENDING` (0x05)
+
+Requests the peer stop sending on a stream. The peer should respond with a `RESET_STREAM`.
+
+```text
++--------+-----------+------------+
+| Type | Stream ID | Error Code |
+| 1 byte | VarInt | VarInt |
++--------+-----------+------------+
+```
+
+#### `APPLICATION_CLOSE` (0x1d)
+
+Gracefully closes the connection with an error code and reason.
+
+```text
++--------+------------+--------+
+| Type | Error Code | Reason |
+| 1 byte | VarInt | UTF-8 |
++--------+------------+--------+
+```
+
+### VarInt Encoding
+
+Variable-length integers use the [QUIC VarInt encoding](https://datatracker.ietf.org/doc/html/rfc9000#section-16).
+The first two bits indicate the length:
+
+| Prefix | Length | Max Value |
+|--------|--------|-----------|
+| `00` | 1 byte | 63 |
+| `01` | 2 bytes | 16,383 |
+| `10` | 4 bytes | 1,073,741,823 |
+| `11` | 8 bytes | 4,611,686,018,427,387,903 |
+
+### Limitations
+
+Let's be real: this is a polyfill, not a replacement.
+
+- **Head-of-line blocking**: TCP delivers bytes in order, so a lost packet stalls everything. The whole point of QUIC streams is to avoid this.
+- **No prioritization**: The sender can't choose which stream gets bandwidth first. With QUIC, we prioritize new video over old video — over WebSocket, they're all stuck in line.
+- **No partial reliability**: You can reset a logical stream, but the bytes already in the TCP buffer will still be delivered (and block everything behind them).
+
+It's good enough for low-congestion scenarios and ensures your app works everywhere.
+For the best experience, use a browser that supports WebTransport.
+
+### Future
+
+There is a [WebTransport over HTTP/2 draft](https://datatracker.ietf.org/doc/html/draft-ietf-webtrans-http2-13) that could replace this WebSocket polyfill.
+It's too little, too late for now, but maybe one day.
+In the meantime, WebSocket is the only reliable fallback for Safari and older iOS devices.
diff --git a/doc/concept/layer/web-transport.md b/doc/concept/layer/web-transport.md
new file mode 100644
index 0000000000..30e014ba53
--- /dev/null
+++ b/doc/concept/layer/web-transport.md
@@ -0,0 +1,54 @@
+---
+title: WebTransport
+description: The layer that makes MoQ browser compatible
+---
+
+# WebTransport
+
+[RFC9000](https://datatracker.ietf.org/doc/html/rfc9000) introduced QUIC and [RFC9114](https://datatracker.ietf.org/doc/html/rfc9114) introduced HTTP/3.
+
+However, HTTP/3 is not the only way to use QUIC in a browser.
+HTTP semantics can make things awkward as the client needs to initiate each request; there's no (good) way for the server to push live content.
+
+WebTransport was created as an alternative to WebSockets, using QUIC instead of TCP.
+It exposes more-or-less the same API as QUIC so it works great for MoQ.
+
+- [Network Specification](https://www.ietf.org/archive/id/draft-ietf-webtrans-http3-14.html)
+- [Browser Specification](https://www.w3.org/TR/webtransport/)
+
+## Handshake
+
+WebTransport actually uses HTTP/3 under the hood.
+A `CONNECT` request is sent by the client to the server, indicating it wants to establish a WebTransport session.
+There's also an optional `protocol` header similar to ALPN and WebSocket's subprotocol.
+
+This sharing of a QUIC connection actually makes WebTransport a bit problematic.
+HTTP/3 requests and other WebTransport sessions fight for resources, requiring extra management in the form of capsules.
+Both sides negotiate the maximum number of WebTransport sessions that can be established on the same QUIC connection; my recommendation is to set it to 1.
+
+## API
+
+There are some subtle differences between the WebTransport API and the QUIC API.
+Most of the time, you can assume they are the same, but there are some differences:
+
+1. **Stream ID Gaps**: Because WebTransport shares a connection, we can't always tell if a RESET stream was for a WebTransport session or a HTTP/3 request.
+2. **Smaller Errors**: WebTransport supports a smaller set of error codes than QUIC.
+3. **Browser API**: The [W3C API](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API) is more limited than most QUIC libraries.
+
+Shameless plug: use my [web-transport](/lib/rs/crate/web-transport) libraries for Rust.
+It implements most of the Quinn API so you can support both QUIC and WebTransport with minimal changes.
+
+## Why Not HTTP/3?
+
+MoQ could use HTTP/3 directly instead of WebTransport, but HTTP semantics make it awkward:
+
+- **With WebTransport**: both sides can create streams whenever and immediately write new frames.
+- **With HTTP/3**: only the client can create a stream (HTTP request), as HTTP push is gone and a mistake anyway. The client needs to know when the server wants to write a new stream.
+
+[moq-relay](/bin/relay/) does provide an HTTP endpoint so a client can still request content on-demand instead of subscribing.
+This is useful for backwards compatibility with HLS, but the long-term goal is to make publishing and subscribing symmetrical via WebTransport.
+
+## Native Clients
+
+Native MoQ clients can skip WebTransport entirely and use QUIC directly via an ALPN.
+This avoids the HTTP/3 handshake overhead and the quirks of sharing a connection.
diff --git a/doc/concept/standard/cmaf.md b/doc/concept/standard/cmaf.md
new file mode 100644
index 0000000000..4967557494
--- /dev/null
+++ b/doc/concept/standard/cmaf.md
@@ -0,0 +1,6 @@
+---
+title: CMAF
+description: Common Media Application Format (CMAF) for streaming media
+---
+
+TODO
diff --git a/doc/concept/standard/index.md b/doc/concept/standard/index.md
new file mode 100644
index 0000000000..9bc2eb0353
--- /dev/null
+++ b/doc/concept/standard/index.md
@@ -0,0 +1,47 @@
+---
+title: Standards
+description: IETF drafts and protocol specifications
+---
+
+# Standards
+
+MoQ is a big tent, full of many different opinions and ideas.
+I consider any media protocol that uses QUIC to be part of MoQ, even if it's part of a standards body or organization.
+
+Additionally, MoQ is experimental and not yet battle-tested, so expect all of these standards to change.
+If you're interested in participating, join any of these communities and get involved.
+
+## IETF MoQ Working Group
+
+The IETF MoQ Working Group is the official standardization body for MoQ.
+The group is primarily focusing on the [MoqTransport](/concept/standard/moq-transport) specification, but there's a number of other drafts too.
+
+There's no membership fee or criteria to join.
+If you want to participate, you should show up to the regular online (and in-person) meetings.
+Once you get more involved, jump into the excessive number of [GitHub issues](https://github.com/moq-wg/moq-transport/issues) and join the [mailing list](https://mailarchive.ietf.org/arch/browse/moq/).
+
+- [Working Group](https://datatracker.ietf.org/group/moq/about/)
+- [Documents](https://datatracker.ietf.org/group/moq/documents/)
+- [GitHub](https://github.com/moq-wg/moq-transport)
+
+## moq.dev
+
+[moq.dev](https://moq.dev) is an open-source implementation of MoQ primarily focused on production usage.
+
+The goal is to support compatibility with the IETF drafts, but not a full implementation.
+The IETF process is slow and involves a lot of debate, discussion, and negotiation.
+All of this is *on purpose* and produces a better standard in the end.
+
+But the standard is too immature, full of bloat, and there's too much churn.
+If we had to gate every change behind IETF approval, it would take months to make even the smallest change.
+
+To that end, we've created a forwards-compatible subset of [MoqTransport](/concept/standard/moq-transport) called [moq-lite](/concept/layer/moq-lite).
+moq-lite is forwards compatible with moq-transport, so it works with any moq-transport CDN (ex. [Cloudflare](https://moq.dev/blog/first-cdn)).
+
+On the media side, there are the [MSF](/concept/standard/msf) (catalog) and [LOC](/concept/standard/loc) (container) drafts.
+They are too early/unstable to be useful, so we're using a custom [hang](/concept/layer/hang) media format instead.
+
+- [Website](https://moq.dev)
+- [GitHub](https://github.com/moq-dev/moq)
+- [Documentation](https://doc.moq.dev)
+- [Discord](https://discord.gg/FCYF3p99mr)
diff --git a/doc/concept/standard/interop.md b/doc/concept/standard/interop.md
new file mode 100644
index 0000000000..af8f0ac972
--- /dev/null
+++ b/doc/concept/standard/interop.md
@@ -0,0 +1,90 @@
+# Interoperability
+
+Want to test some standards against this code?
+
+It's recommended that you run the code locally for easier debugging. See the [Setup Guide](/setup/dev), it's pretty easy.
+
+## Transport
+
+[moq-lite](/concept/layer/moq-lite) is a forwards-compatible subset of moq-transport.
+A moq-lite client can talk to any moq-transport relay, but not necessarily the other way around.
+
+### Versions
+
+- moq-transport-14
+- moq-transport-15 (untested)
+- moq-transport-16
+- moq-transport-17
+
+### Protocols
+
+- WebTransport
+- QMux over WebSocket
+- (Rust) QUIC
+- (Rust) QMux over TLS
+
+### Client
+
+If you want to test your relay:
+
+```bash
+# Rust: Publishes a media broadcast
+just pub bbb https://
+
+# Javascript: Subscribes to a media broadcast
+just web https://
+```
+
+> **Note:** WebTransport automatically prepends the URL path (e.g., `/anon`) to broadcast names. Raw QUIC and iroh have no HTTP layer, so you must manually include that prefix in the broadcast name (e.g., publish as `/anon/bbb`).
+
+The publisher sends a `PUBLISH_NAMESPACE` and the subscriber **requires** a `SUBSCRIBE_NAMESPACE`.
+If you haven't implemented the latter yet, remove `reload` in `demo/web/src/index.html`.
+
+**Feeling spicy?**
+You can use [gstreamer](/bin/gstreamer) or [obs](/bin/obs) too, both support publishing and subscribing.
+There's also bindings for Rust, Javascript, C, Python, Kotlin, Swift, etc.
+
+### Relay
+
+The relay is less useful to test against because we purposely implement a shallow subset of the moq-transport draft.
+
+```bash
+# Rust: Runs a localhost relay
+# Also available at: https://cdn.moq.dev/anon
+just relay
+```
+
+The relay **WILL IGNORE** the following:
+
+- Any sub-group >0
+- Any datagrams
+- Any FETCH, except JOINING FETCH is a no-op.
+- Any objects with delta >0 (must be contiguous)
+- Any object properties
+- Any SUBSCRIBE `forward=0`
+- Any multi-publisher nonsense
+- Probably some other stuff
+
+Note that all subscriptions start at the latest group.
+
+## Media
+
+I'm primarily using `hang`, which is like a mix between MSF + LOC.
+I'd love to standardize eventually but I need more media homies that want to interop, not just write a spec.
+
+### Rust
+
+The publisher currently makes two catalog tracks pointing to the same media tracks:
+
+- `catalog.json` ([hang](/concept/layer/hang))
+- `catalog` ([MSF](/concept/standard/msf))
+
+We support two possible containers, but currently `cmaf` is experimental:
+
+- `legacy` ([hang](/concept/layer/hang))
+- `cmaf` ([CMAF](/concept/standard/cmaf))
+
+### Javascript
+
+The Javascript code currently only supports `catalog.json`.
+However, the subscriber can consume both `legacy` and `cmaf` containers
diff --git a/doc/concept/standard/loc.md b/doc/concept/standard/loc.md
new file mode 100644
index 0000000000..0ae41c600c
--- /dev/null
+++ b/doc/concept/standard/loc.md
@@ -0,0 +1,14 @@
+---
+title: LOC - Low Overhead Container
+description: A low-overhead container format for MoQ.
+---
+
+# LOC - Low Overhead Container
+
+We originally wanted to use [CMAF](/concept/standard/msf) but there's a lot of overhead.
+Like 100 bytes per frame sort of overhead (`moof` + `mdat`), the type of overhead that kills audio-only streams.
+
+LOC is a super simple container format that's designed to be lightweight.
+It's similar to the [hang container](../layer/hang) and we'll probably merge them in the future.
+
+[See the draft](https://www.ietf.org/archive/id/draft-ietf-moq-loc-00.html) for the latest details.
diff --git a/doc/concept/standard/moq-transport.md b/doc/concept/standard/moq-transport.md
new file mode 100644
index 0000000000..f7688ead9b
--- /dev/null
+++ b/doc/concept/standard/moq-transport.md
@@ -0,0 +1,110 @@
+---
+title: MoqTransport
+description: The generic pub/sub protocol that powers everything.
+---
+
+# MoqTransport
+
+The generic pub/sub protocol that powers everything.
+It doesn't know anything about media - it just moves bytes around really efficiently.
+CDNs implement this layer, and it's designed to scale to millions of subscribers.
+
+This guide assumes that you are familiar with [moq-lite](/concept/layer/moq-lite).
+Many of the concepts are the same and it's easier to point out the differences rather than start from scratch.
+
+- [Latest Draft](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/)
+
+## Model
+
+### Namespace
+
+A named collection of related **tracks**, called a `Broadcast` in moq-lite.
+Each namespace is identified by an array of byte arrays, but most people use an array of strings.
+
+Everything is scoped, so if you `SUBSCRIBE_NAMESPACE` to `["sports", "nba"]` you'll also get `["sports", "nba", "highlights"]`.
+MoqTransport also has the `forward=1` flag to automatically subscribe to any `PUBLISH`'d tracks in the namespace.
+
+Unlike moq-lite, a namespace *could* be shared by multiple publishers.
+However, I don't think CDNs will ever implement this feature due to conflicts/complexity/bugs.
+
+### Track
+
+A named collection of **groups**, potentially served out-of-order.
+Each track is identified by a byte array, but most people use a string.
+
+Each subscription is scoped to a single track.
+Unlike moq-lite, a track can have multiple publishers.
+Again, I don't think a CDN will ever implement this due to conflicts/complexity/bugs.
+
+### Group
+
+A collection of **sub-groups**, potentially served out-of-order.
+Each group is identified by a monotonically increasing group ID, with gaps allowed.
+
+### Sub-Group
+
+A collection of **frames**, served in order over a single QUIC stream.
+Each sub-group is identified by a sub-group ID, where 0 is considered the base layer.
+
+Sub-groups are intended for layered encodings (SVC), such that an enhancement layer can be dropped without affecting a base layer.
+In moq-lite, you would make a separate track for each layer so you can select/prioritize them independently.
+
+### Object
+
+The smallest unit in MoQ: a sized chunk of data.
+Each object is identified by a monotonically increasing object ID.
+
+Unlike moq-lite, there may be both explicit and implicit gaps in the object ID.
+There are also "Object Properties" that can be used to store K/V metadata pairs.
+
+## Request
+
+MoqTransport has a bunch of different request types, each with a different purpose.
+They are identified by a request ID and have corresponding \_OK and \_ERROR responses.
+
+Note that `moq-lite` contains the equivalent of: `SUBSCRIBE` and `SUBSCRIBE_NAMESPACE`.
+The other requests are unique to MoqTransport.
+
+### SUBSCRIBE
+
+Allows access to future objects.
+When a new object is available, each active subscriber will receive a copy of it over the corresponding sub-group QUIC stream.
+
+Unlike moq-lite, a subscription starts at the latest object within a group, not the first object.
+This is quite annoying because you have to issue a `JOINING FETCH` request to get the earlier objects within the group (ex. the I-frame).
+
+Unlike moq-lite, there is a `forward=0` flag to pause a subscription.
+
+### PUBLISH
+
+A publisher can start pushing subscription data rather than waiting for a `SUBSCRIBE`.
+
+This can avoid a round-trip when the peer definitely wants to receive the track.
+It can also be used to discover track names, as that information is not available in `PUBLISH_NAMESPACE`
+
+### SUBSCRIBE\_NAMESPACE
+
+Announces that a namespace is newly available or unavailable.
+There's no information about the tracks within the namespace.
+
+Unlike moq-lite, there is a `forward=1` flag to automatically receive any `PUBLISH`'d tracks in the namespace.
+
+### PUBLISH\_NAMESPACE
+
+Announces the availability of a namespace.
+There's no information about the tracks within the namespace.
+
+Unlike moq-lite, this can be sent without a corresponding `SUBSCRIBE_NAMESPACE`.
+
+### FETCH
+
+Allows access to past objects.
+All objects that match the provided range will be delivered in Group ID, Object ID order over a single QUIC stream.
+
+FETCH appears nice on paper, but there are numerous issues due to the HoLB blocking and implicit gaps.
+I would strongly encourage fetching objects via HTTP or some other mechanism for now.
+
+### JOINING FETCH
+
+A special variant of FETCH that is relative to an existing subscription.
+This is used to get the prior objects within a group (ex. the I-frame).
diff --git a/doc/concept/standard/msf.md b/doc/concept/standard/msf.md
new file mode 100644
index 0000000000..ceefbda891
--- /dev/null
+++ b/doc/concept/standard/msf.md
@@ -0,0 +1,15 @@
+---
+title: MSF - MoQ Streaming Format
+description: A catalog format for MoQ.
+---
+
+# MSF - MoQ Streaming Format
+
+HLS/DASH playlists suck.
+WebRTC SDP is even worse.
+MSF is a replacement for both, utilizing MoQ live streams.
+
+[MSF](https://www.ietf.org/archive/id/draft-ietf-moq-msf-00.html) is a catalog format for MoQ.
+It's similar to the [hang catalog](../layer/hang) and we'll probably merge them in the future.
+
+[See the draft](https://www.ietf.org/archive/id/draft-ietf-moq-msf-00.html) for the latest details.
diff --git a/doc/concept/use-case/ai.md b/doc/concept/use-case/ai.md
new file mode 100644
index 0000000000..4eb9ace38c
--- /dev/null
+++ b/doc/concept/use-case/ai.md
@@ -0,0 +1,79 @@
+---
+title: AI
+description: Welcome to the future, old man.
+---
+
+# AI
+
+Hopefully you had this square on your buzzword bingo card.
+
+WebRTC is a great protocol for conferencing, but it's not designed for AI.
+But I haven't personally worked in this space either so take my suggestions with a grain of salt.
+
+## Latency
+
+Inference is still quite slow and expensive, even for the big players.
+If you're going to spend >300ms and literal dollars on expensive inference, you want at least *some* reliability guarantees.
+
+Unfortunately, WebRTC will never try to retransmit audio packets.
+A single lost packet will cause noticeable audio distortion.
+And if you have the audacity to generate audio/video separately, WebRTC won't synchronize them for you.
+Frames are rendered on receipt, so unless you introduce a delay, audio will be out of sync with video.
+
+One of the core tenets of MoQ is adjustable latency.
+The viewer (and thus your application) controls how long it's willing to wait for content before it gets skipped/desynced.
+The latency budget of the network protocol can match the latency budget of the application.
+
+## On-Demand
+
+MoQ is pull-based, so nothing is transmitted over the network until there's at least one subscriber.
+You can further extend this by not generating/encoding content either.
+
+Both of these were mentioned briefly on the [contribution](/concept/use-case/contribution) page if you want to read more.
+
+### Inference
+
+If you want to save compute resources, you can defer inference until it's actually needed.
+
+For example, let's say you're publishing a `captions` track populated by Whisper or something.
+If nobody has enabled captions, then nobody will subscribe to the `captions` track.
+You can stop generating the track (or use a smaller model) until it's actually requested.
+
+### Simulcast
+
+If you want to save bandwidth, you can publish media in a format expected by the AI model.
+
+For example, let's say you're doing object detection on a bunch of security cameras.
+The model inputs video at 360p and 10fps or something like that, so that's what you publish.
+But if a human (those still exist) wants to audit the full video, you can separately serve the full resolution video.
+Since this is on-demand, you will only encode/transmit the 1080p video when it's actually needed.
+
+## Browser Control
+
+One of the perks of using WebSockets/MoQ instead of WebRTC is that you get full control over the media pipeline.
+
+[WebCodecs](https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API) is used to encode/decode media within the browser.
+
+- For video, you use [VideoFrame](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame) which directly maps to a texture on the GPU. You can use WebGPU to perform inference, encoding, rendering, etc without ever touching the CPU.
+- For audio, you get [AudioData](https://developer.mozilla.org/en-US/docs/Web/API/AudioData) which is (usually) just a float32 array. You control exactly how these are processed, captured, emitted, etc.
+
+It's more work to do this instead of using a `` element of course, but it opens the door to more possibilities.
+Run additional inference in the browser, render your media to textures on a model, etc.
+
+And note that all of this is possible with WebRTC and [insertable streams](https://developer.mozilla.org/en-US/docs/Web/API/Insertable_Streams_for_MediaStreamTrack_API).
+However, you're really not gaining much by using WebRTC only for networking... just use MoQ instead.
+
+## Non-Media
+
+MoQ is not just for media.
+
+Send your prompts over the same WebTransport connection as the media.
+Or send non-media stuff like vertex data for 3D models, separate from the texture data.
+It's a versatile protocol with a wide range of use-cases.
+
+## Simplicity
+
+You're working with AI, so you're probably building something new.
+
+If you don't want to deal with SDP, or connections that take 10 RTTs, or unsupported media encodings, or STUN/TURN servers, then give MoQ a try.
+It's a lot closer to WebSockets than WebRTC, but with the ability to skip and scale.
diff --git a/doc/concept/use-case/conferencing.md b/doc/concept/use-case/conferencing.md
new file mode 100644
index 0000000000..4afe45edd9
--- /dev/null
+++ b/doc/concept/use-case/conferencing.md
@@ -0,0 +1,40 @@
+---
+title: MoQ vs WebRTC
+description: How MoQ compares to conferencing protocols
+---
+
+# MoQ vs WebRTC
+
+This page compares MoQ with **WebRTC**, the dominant protocol for conferencing.
+
+## Requirements
+
+Boring stuff first.
+Conferencing protocols need to:
+
+- Support any number of participants (publish and subscribe)
+- Support <100ms latency
+- Support a wide range of devices
+- Support a wide range of networks
+
+Some optional features:
+
+- Support browsers (aka WebRTC)
+- End-to-end encryption.
+- Peer-to-peer connections.
+
+## Existing Protocols
+
+- **WebRTC** ([Web Real-Time Communication](https://en.wikipedia.org/wiki/WebRTC)) - The dominant protocol for conferencing.
+- **RTP** ([Real-Time Transport Protocol](https://en.wikipedia.org/wiki/Real-time_Transport_Protocol)) - The core protocol within WebRTC.
+
+While this might make WebRTC seem super dominant, the reality is a little bit more nuanced.
+
+Almost every conferencing service tries to force their native app on you.
+Zoom, Teams, Discord, etc.
+WebRTC is mandatory on the browser, but it's *not* mandatory for native apps.
+A service like Discord uses a custom RTP stack between native apps and only uses WebRTC for browser compatibility.
+
+The only exception is Google Meet.
+Google maintains and controls `libwebrtc`, the core WebRTC implementation in browsers.
+If Google wants a feature, then they add it to WebRTC, while every other service has to find a workaround.
diff --git a/doc/concept/use-case/contribution.md b/doc/concept/use-case/contribution.md
new file mode 100644
index 0000000000..24eb0d6c6c
--- /dev/null
+++ b/doc/concept/use-case/contribution.md
@@ -0,0 +1,140 @@
+---
+title: MoQ vs RTMP/SRT
+description: How MoQ compares to contribution protocols like RTMP and SRT
+---
+
+# MoQ vs RTMP/SRT
+
+This page compares MoQ with traditional **contribution protocols** like RTMP and SRT.
+
+**WARNING**: I have the least experience with contribution protocols.
+I did read the SRT specification once and it made me very sad.
+Take everything with a grain of salt.
+
+## Requirements
+
+Okay the boring stuff first.
+Contribution protocols need to:
+
+- Publish from a client to a server
+- Integrate with encoders and other media sources (ex. OBS)
+- Support a wide range of devices
+
+Some optional features:
+
+- Support browsers
+- Support modern codecs (looking at you, RTMP)
+- Support ad signaling 🤮
+- Support DRM 🤮
+- Support a wide range of networks
+- Support adaptive bitrate
+- Support simulcast (multiple renditions)
+
+## Use-Cases
+
+There's a lot of optional features for contribution protocols.
+I would generalize this into two camps:
+
+1. User generated content (ex. YouTube/Twitch/Facebook)
+2. Studio generated content (ex. SportsBall)
+
+If you focus on large audiences, then you can over-provision bandwidth and compute resources.
+The network protocol doesn't really matter that much; device support and integrations are more important.
+You also need a way to monetize your users via ads and (indirectly) via DRM.
+
+If you focus on small audiences, then the economics start to matter.
+The contribution protocol needs to work on commodity low-end devices and spotty networks.
+It might even be too expensive to transcode content for every broadcaster.
+
+This is an over-generalization of course.
+HUMOR ME.
+
+## Existing Protocols
+
+- **RTMP** ([Real-Time Messaging Protocol](https://en.wikipedia.org/wiki/Real-Time_Messaging_Protocol)) - The classic Flash-era protocol
+- **SRT** ([Secure Reliable Transport](https://en.wikipedia.org/wiki/Secure_Reliable_Transport)) - Modern "low-latency" alternative
+- **E-RTMP** ([Enhanced RTMP](https://en.wikipedia.org/wiki/Real-Time_Messaging_Protocol#Enhanced_RTMP)) - Modernized version of RTMP
+- **WebRTC** ([Web Real-Time Communication](https://en.wikipedia.org/wiki/WebRTC)) - Can be used for contribution via [WHIP](https://www.rfc-editor.org/rfc/rfc9725.html)
+- **RTSP** ([Real-Time Streaming Protocol](https://en.wikipedia.org/wiki/Real-Time_Streaming_Protocol)) - Used in IP cameras
+
+User-generated content (YouTube/Twitch/Facebook) primarily uses RTMP.
+Studio-generated content primarily uses SRT.
+
+## Pull vs Push
+
+Existing contribution protocols are push-based.
+Even Youtube's weird HLS ingest thing operates via POST requests.
+
+However, MoQ is fundamentally a pull-based protocol.
+Technically, MoqTransport supports push too (via PUBLISH), but hear me out for a second.
+
+### The Push Problem
+
+I would say there is one major problem with push: **Nothing is optional.**
+
+When a publisher creates multiple tracks, like 360p and 1080p, it needs to simultaneously encode and transmit both tracks.
+There's no way of knowing if anything downstream *actually* wants the 1080p track; it might go straight to `/dev/null` on the media server.
+
+This doesn't matter for huge events like a concert or sports game.
+With enough viewers, we can assume that at least one viewer will want the content.
+But it can be a significant cost for long-tail content that nobody watches.
+
+For example, consider a facility with hundreds of security cameras.
+We might be able to afford uploading 360p for every camera (recording to disk), but anything more than that would over-saturate the network.
+Ideally, we could only stream 1080p from individual cameras when a human wants a closer look...
+
+### The Pull Solution
+
+The first thing a MoQ viewer does is subscribe to the `catalog.json` track for a broadcast.
+This lists all of the available tracks and their properties.
+
+If a viewer wants the 1080p track, it subscribes to it.
+The subscription makes its way upstream (combining with duplicates) until one subscription reaches the publisher.
+When no more viewers want the 1080p track, the subscription is cancelled.
+
+The publisher won't transmit a track until there's an active subscription, saving bandwidth.
+The publisher can go the extra mile and not even encode the content without a subscription, saving compute.
+This is especially useful for expensive AI models, for example only running whisper when captions are needed.
+
+Note that media services can also benefit from the same behavior.
+If nobody currently wants the 1080p track, then don't transcode it.
+The "publisher" in this case is any entity that understands the media format on top of MoQ.
+
+## Multiple Connections
+
+Another issue with push-based protocols is that each connection is expensive.
+If every connection needs its own copy of the content, we quickly run out of bandwidth.
+Redundant ingest is mostly limited to large events that have bandwidth to spare (active-active).
+
+Once again, MoQ solves this via the pull model.
+A publisher can establish multiple connections that *might* be used.
+A subscription will only be issued if the connection needs a specific track.
+
+For example, a service can implement primary/secondary ingest via two connections to separate endpoints.
+All subscriptions are issued over the primary connection but if it fails, the subscriptions are moved to the secondary connection.
+The endpoints don't even have to be part of the same CDN and MoQ publisher is completely oblivious; it just knows it was told to connect to two URLs.
+
+Another example is P2P streaming.
+A client can establish a connection to each peer, transmitting tracks as requested.
+If one peer has the video minimized, then it can unsubscribe from the video track and save bandwidth.
+Again there's no business logic for this built into MoQ: it's automatic.
+
+But what about clients that don't support P2P?
+Each client can also establish a connection to a MoQ CDN as a fallback.
+This works because the client discovers all available broadcasts available on a connection via the built-in [announce mechanism](/concept/layer/moq-lite).
+If two connections can serve the same content, the subscription goes to the "best" connection (ie. P2P > CDN).
+
+## Economies of Scale
+
+A subtle problem with contribution protocols is that they're not used for distribution.
+
+This might be silly: "of course distribution and contribution are different!"
+But when you really sit down and break down the requirements, they're not that different.
+One is client-server while the other is server-client, one is 1:1 while the other is 1:N.
+
+By designing a protocol that works for both contribution and distribution, we can share implementations and optimizations.
+There are other benefits of supporting 1:N too, as mentioned in the previous section, so it seems like a no-brainer.
+
+The other way we benefit from economies of scale is by using QUIC.
+We're not implementing our own UDP-based protocol and rediscovering the rough edges of the internet all over again.
+A QUIC library with BBR will out-perform the system TCP stack and likely out-perform any custom UDP thing (ex. SRT).
diff --git a/doc/concept/use-case/distribution.md b/doc/concept/use-case/distribution.md
new file mode 100644
index 0000000000..fc56878d55
--- /dev/null
+++ b/doc/concept/use-case/distribution.md
@@ -0,0 +1,155 @@
+---
+title: MoQ vs HLS/DASH
+description: How MoQ compares to distribution protocols like HLS/DASH
+---
+
+# MoQ vs HLS/DASH
+
+This page compares MoQ with traditional **distribution protocols** like HLS and DASH.
+
+## Requirements
+
+Okay the boring stuff first.
+Distribution protocols need to:
+
+- Support any number of viewers
+- Support a wide range of devices
+- Support a wide range of networks
+- Support web browsers
+- Support multiple renditions (ABR)
+
+Some optional features:
+
+- Support VOD and DVR
+- Support DRM 🤮
+
+## Existing Protocols
+
+- **HLS** ([HTTP Live Streaming](https://en.wikipedia.org/wiki/HTTP_Live_Streaming)) - Apple's protocol. Used to be required for iOS, now mainly for Airplay.
+- **DASH** ([Dynamic Adaptive Streaming over HTTP](https://en.wikipedia.org/wiki/Dynamic_Adaptive_Streaming_over_HTTP)) - An MPEG standard that copies Apple but does it "better".
+- **LL-HLS** ([Low-Latency HLS](https://en.wikipedia.org/wiki/HTTP_Live_Streaming#Low_Latency_HLS)) - A variant of HLS for lower latency.
+- **LL-DASH** ([Low-Latency DASH](https://optiview.dolby.com/resources/blog/streaming/low-latency-dash/)) - Ditto.
+
+The notable thing here is that HLS/DASH both use HTTP and benefit greatly from existing HTTP infrastructure.
+A "state-less" protocol like HTTP is perfect for distribution because it can be cached and fanned out.
+We're mostly going to be discussing HLS/DASH in the rest of this document.
+
+Notable mentions:
+
+- **Sye** ([Sye](https://www.aboutamazon.eu/news/job-creation-and-investment/how-homegrown-swedish-streaming-technology-went-worldwide-with-amazon-and-prime-video)) - Prime Video's protocol they force (with money) every CDN to support.
+- **RTMP** ([Real-Time Messaging Protocol](https://en.wikipedia.org/wiki/Real-Time_Messaging_Protocol)) - The classic Flash-era protocol before we switching to HTTP.
+- **WebRTC** ([Web Real-Time Communication](https://en.wikipedia.org/wiki/WebRTC)) - Can be used for distribution, but it's not designed for it.
+
+RTMP and WebRTC can technically offer a better user experience with lower latency, but they don't benefit from economies of scale.
+A specialized RTMP/WebRTC CDN does not have the same level of optimization as a general-purpose HTTP CDN.
+
+## Latency
+
+Fun fact, I (`kixelated`) created MoQ because we hit the latency limits of HLS.
+We were already using a `LHLS` variant for streaming frame-by-frame well before LL-HLS was a thing.
+
+### The Problem
+
+HLS/DASH are known for their high latency.
+Even LL-HLS and LL-DASH can only achieve the ~2s-3s latency range on a good network.
+
+The fundamental problem is how HLS/DASH respond to congestion.
+The protocols work by downloading segments of media (ex. 2s) sequentially over HTTP.
+When using HTTP/1 and HTTP/2, this means each segment of media is served over TCP.
+
+When the network goes to shit, the current segment of media starts queuing, which also blocks the next segment from being served.
+The queue builds up in a phenomenon not too different from [bufferbloat](https://en.wikipedia.org/wiki/Bufferbloat).
+What's worse, the player can't\* really switch renditions until the next segment boundary, so it's stuck downloading the rest of the segment at an unsustainable bitrate.
+
+\* Canceling the request would sever a HTTP/1 connection, requiring a slow TCP/TLS handshake. [HESP](https://en.wikipedia.org/wiki/High_Efficiency_Streaming_Protocol) is one work-around but it's complicated.
+
+A HLS/DASH player uses a large buffer size so it can continue to play media even during these situations.
+When the buffer is empty, playback will freeze and you get that dreaded "buffering" spinner.
+Your latency will be higher after the buffer is refilled to help avoid this situation in the future.
+
+It also doesn't help that segments are not streamed, which means a 2s segment sits on disk for 2s adding additional latency.
+LL-HLS and LL-DASH address this by using smaller segments (~500ms), but still suffer from the same queuing problem.
+
+### A Solution
+
+MoQ solves this by using QUIC to stream segments in parallel.
+And of course, segments are streamed instead of sitting on disk to further reduce latency.
+
+During normal operation, the player will be near the live playhead and downloading the current segment.
+When the network starts go to shit, the player will start to fall behind on the current segment like HLS/DASH.
+
+However, when the next segment boundary is reached, the MoQ server will open a new QUIC stream for it.
+This QUIC stream has a higher priority than the previous stream, so when bandwidth is limited, it will be served first.
+To the player, the current segment will stall while the next segment (at a lower rendition) starts downloading.
+If there's any bandwidth available, the current segment will make some progress but it (probably) won't be able to catch up.
+
+Eventually, the player hits the maximum jitter buffer size and skips the current segment.
+The user experience is like that of conferencing, where the old content might stutter before warping forward.
+
+But note that audio is higher priority than video too.
+There will be some desync during congestion events, but a continuous stream of audio helps smooth out the user experience.
+
+### Dynamic Experience
+
+While MoQ unlocks this new user experience, it won't always be desirable.
+A user might *want* to buffer so they don't miss any content.
+
+MoQ copies the HLS/DASH playbook and puts the viewer in control of the buffer size, and thus the target latency
+The server will prioritize newer content only when indicated in the SUBSCRIBE request.
+You can use the same protocol to deliver content with 100ms of latency or 10s of latency without a significant behavioral change.
+
+Other protocols like WebRTC/SRT don't have this flexibility; the publisher decides when to drop content.
+It doesn't matter how big the viewer's buffer is if WebRTC decides to drop after 100ms of congestion.
+MoQ gives you the ability to distribution to a diverse set of viewers with different latency requirements.
+
+### HTTP/3
+
+One thing I want to mention is that MoQ does not use HTTP.
+Ouch, I just said that HTTP was crucial for HLS/DASH.
+
+One problem is that HTTP, by design, is version agnostic.
+When you make a HTTP request, it could be using HTTP/1, HTTP/2, or HTTP/3.
+Most of the time this doesn't matter, but it can put us in a bad situation unless we explicitly require a specific version.
+
+For example, [prioritizing a HTTP request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Priority):
+
+- HTTP/1: Does nothing. TCP connections fight for bandwidth instead.
+- HTTP/2: Prioritizes the new request, but data might still be stuck in the TCP queue.
+- HTTP/3: Prioritizes the new request.
+
+Any variant of MoQ over HTTP would have to require at least HTTP/2.
+Otherwise, prioritizing a HTTP/1 request would be disastrous, as segments would fight for bandwidth rather than cooperate.
+This is a solvable issue of course and I would love to see a protocol like HLS/DASH that copies MoQ's prioritization.
+
+The main reason why we use WebTransport is just to avoid HTTP semantics.
+Only the HTTP client can issue a HTTP request and there's no way (any longer) for the server to push content to the client.
+
+We could totally have the client increment a sequence number (like HLS/DASH) and constantly request the next segment.
+But then when doing [contribution](/concept/use-case/contribution), the client/server dynamic is inverted.
+Rather than fight HTTP semantics, we're using a proper bidirectional protocol like QUIC/WebTransport instead.
+
+### Why it doesn't Matter
+
+Remember that economies of scale are gud.
+We can still get the benefits of HTTP without using HTTP.
+
+First off, we did the next best thing and based the protocol on QUIC.
+There's a production-grade QUIC library behind every HTTP/3 implementation.
+Any CDNs that support HTTP/3 have the capability to slot MoQ support in with minimal effort.
+
+Second off, the [MoqTransport](/concept/standard/moq-transport) layer is generic.
+It's an ambitious attempt to surplant HTTP for live content, not just media content.
+More use-cases means more emdna which means more customers which means more investment which means you get the idea.
+
+### Device Support
+
+The biggest uphill battle for MoQ is device support.
+
+HTTP has powered the internet for decades and it's not going anywhere anytime soon.
+HTTP/3 is a new kid on the block, and MoQ can ride that momentum, but it's going to take years before every device supports QUIC.
+
+I think the most crucial feature for MoQ is backwards compatibility with HLS/DASH.
+The ability to serve HLS/DASH content via MoQ and HTTP is necessary for the industry to adopt MoQ.
+
+That's why [hang](/concept/layer/hang) can support CMAF and [moq-relay](/bin/relay/) can serve tracks via HTTP.
+More documentation coming soon.
diff --git a/doc/concept/use-case/index.md b/doc/concept/use-case/index.md
new file mode 100644
index 0000000000..4558733c4c
--- /dev/null
+++ b/doc/concept/use-case/index.md
@@ -0,0 +1,12 @@
+---
+title: Use Cases
+description: How MoQ should be used in the wild
+---
+
+# Use Cases
+
+- [Contribution](/concept/use-case/contribution): A publisher (ex. OBS) sends data to a service (ex. Twitch).
+- [Distribution](/concept/use-case/distribution): A service (ex. Twitch) distributes data to viewers.
+- [Conferencing](/concept/use-case/conferencing): A service (ex. Zoom) facilitates a conference between multiple participants.
+- [AI](/concept/use-case/ai): Generative AI, overlays, voice agents, and more.
+- [Other](/concept/use-case/other): Some ideas for other use cases that might be viable.
diff --git a/doc/concept/use-case/other.md b/doc/concept/use-case/other.md
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/doc/concepts/architecture.md b/doc/concepts/architecture.md
deleted file mode 100644
index 323869ee80..0000000000
--- a/doc/concepts/architecture.md
+++ /dev/null
@@ -1,267 +0,0 @@
----
-title: Architecture
-description: Understanding MoQ's layered architecture
----
-
-# Architecture
-
-MoQ is designed as a layered protocol stack where each layer has distinct responsibilities. This separation enables flexibility, simplicity, and powerful use cases.
-
-## The Golden Rule
-
-**The CDN MUST NOT know anything about your application, media codecs, or even the available tracks.**
-
-Everything could be fully end-to-end encrypted and the CDN wouldn't care. No business logic allowed.
-
-Instead, `moq-relay` operates on rules encoded in the `moq-lite` header. These rules are based on video encoding patterns but are generic enough to be used for any live data.
-
-## Protocol Layers
-
-```
-┌─────────────────┐
-│ Application │ 🏢 Your business logic
-│ │ - authentication, non-media tracks, etc.
-├─────────────────┤
-│ hang │ 🎬 Media-specific encoding/streaming
-│ │ - codecs, containers, catalog
-├─────────────────├
-│ moq-lite │ 🚌 Generic pub/sub transport
-│ │ - broadcasts, tracks, groups, frames
-├─────────────────┤
-│ WebTransport │ 🌐 Browser-compatible QUIC
-│ QUIC │ - HTTP/3 handshake, multiplexing, etc.
-└─────────────────┘
-```
-
-## Layer Details
-
-### QUIC Layer
-
-The foundation providing:
-
-- **Streams** - Independent, ordered, reliable delivery channels
-- **Multiplexing** - Many streams over a single connection
-- **Prioritization** - Send important data first
-- **Partial reliability** - Reset streams carrying stale data
-- **Security** - TLS 1.3 built-in
-- **0-RTT** - Resume connections without handshake
-
-MoQ maps each group to a QUIC stream for independent delivery and prioritization.
-
-### WebTransport Layer
-
-A browser API exposing QUIC to web applications:
-
-- **HTTP/3 based** - Leverages existing web infrastructure
-- **Bidirectional streams** - For request/response patterns
-- **Unidirectional streams** - For efficient one-way data
-- **Datagrams** - For unreliable, low-latency messages
-
-In browsers, WebTransport is the only way to access QUIC. On the server side, native QUIC libraries like [Quinn](https://github.com/quinn-rs/quinn) provide more control.
-
-### moq-lite Layer
-
-The core pub/sub transport protocol:
-
-**Primitives:**
-- **Broadcasts** - Collections of related tracks (like a "room" or "channel")
-- **Tracks** - Named streams of sequential groups
-- **Groups** - Collections of frames with independent delivery
-- **Frames** - Sized payloads of bytes
-
-**Features:**
-- Built-in concurrency and deduplication
-- Group-level prioritization rules
-- Track discovery and announcement
-- Path-based scoping
-
-**Key insight:** The relay operates purely on these primitives without understanding what's inside frames. This enables:
-- End-to-end encryption
-- Custom media formats
-- Non-media use cases
-- Simple relay implementation
-
-Think of `moq-lite` as **HTTP** - a generic transport layer.
-
-### hang Layer
-
-Media-specific encoding/streaming built on top of `moq-lite`:
-
-**Components:**
-- **Catalog** - JSON track listing available tracks and their properties
-- **Container** - Simple frame format: timestamp + codec bitstream
-- **Codecs** - Support for H.264/265, VP8/9, AV1, AAC, Opus
-
-**Features:**
-- WebCodecs compatibility
-- Dynamic quality selection
-- Track discovery via catalog
-- Media-agnostic relay
-
-Think of `hang` as **HLS/DASH** - a media-specific format.
-
-The beauty: if you want something custom, extend or replace `hang` while keeping `moq-lite` unchanged.
-
-### Application Layer
-
-Your business logic:
-
-- Authentication and authorization
-- Custom track types (chat, telemetry, etc.)
-- Application-specific metadata
-- User interface and experience
-
-## Data Flow
-
-### Publishing Flow
-
-1. **Capture** - Get media from camera, file, or generator
-2. **Encode** - Convert to codec bitstream (H.264, Opus, etc.)
-3. **Group** - Organize frames into groups (typically keyframe + deps)
-4. **Wrap** - Add timestamp and metadata
-5. **Publish** - Send to relay via `moq-lite`
-
-### Relay Flow
-
-1. **Accept** - Publisher connects and authenticates
-2. **Store** - Keep recent groups in memory (cache)
-3. **Route** - Forward to subscribers based on path
-4. **Prioritize** - Send keyframes and recent data first
-5. **Backpressure** - Drop old data when subscriber is slow
-
-### Subscription Flow
-
-1. **Connect** - Subscriber connects to relay
-2. **Subscribe** - Request specific tracks
-3. **Receive** - Get groups as QUIC streams
-4. **Decode** - Extract frames and decode codec bitstream
-5. **Render** - Display video/audio or process data
-
-## Component Architecture
-
-### moq-relay
-
-A stateless relay server that:
-
-- Accepts WebTransport connections
-- Routes broadcasts between publishers and subscribers
-- Performs fan-out to multiple subscribers
-- Supports clustering for geographic distribution
-- Uses JWT tokens for authentication
-- Runs on any cloud provider with UDP support
-
-Relay instances can be clustered:
-- Each relay connects to others
-- Publishers can be in different regions
-- Subscribers connect to nearest relay
-- Relays forward broadcasts between regions
-
-### hang-cli
-
-A command-line tool for:
-
-- Publishing media from files or streams
-- Using FFmpeg as input source
-- Testing and development
-- Media server deployments
-
-### Browser Libraries
-
-TypeScript packages for web applications:
-
-- `@moq/lite` - Core protocol implementation
-- `@moq/hang` - Media library with Web Components
-- `@moq/hang-ui` - UI components (SolidJS)
-
-### Native Libraries
-
-Rust crates for server-side and native apps:
-
-- `moq-lite` - Core protocol implementation
-- `hang` - Media library
-- `moq-relay` - Relay server
-- `moq-native` - QUIC endpoint helpers
-- `libmoq` - C bindings
-
-## Design Principles
-
-### 1. Simplicity
-
-Each layer does one thing well:
-- QUIC handles networking
-- moq-lite handles pub/sub
-- hang handles media
-
-### 2. Generality
-
-moq-lite works for any live data:
-- Video streaming
-- Audio conferencing
-- Text chat
-- Sensor data
-- Game state
-- Collaborative editing
-
-### 3. Deployability
-
-The relay is simple enough to:
-- Run on commodity hardware
-- Scale horizontally
-- Deploy globally
-- Operate without media expertise
-
-### 4. End-to-End
-
-Applications control:
-- Media formats and quality
-- Encryption and security
-- Business logic
-- User experience
-
-The relay just moves bytes efficiently.
-
-## Comparison to Other Protocols
-
-### vs WebRTC
-
-**Similarities:**
-- Real-time latency
-- QUIC/UDP based
-- Browser support
-
-**Differences:**
-- MoQ: Fan-out via relay (1-to-many)
-- WebRTC: Peer-to-peer (1-to-1) or SFU (complex)
-- MoQ: Application controls media pipeline
-- WebRTC: Browser controls media pipeline
-
-### vs HLS/DASH
-
-**Similarities:**
-- Adaptive bitrate
-- Wide compatibility
-
-**Differences:**
-- MoQ: Real-time latency (< 1 second)
-- HLS/DASH: High latency (5-30 seconds)
-- MoQ: Live-first design
-- HLS/DASH: VOD-first design
-
-### vs RTMP/SRT
-
-**Similarities:**
-- Low latency
-- Live streaming
-
-**Differences:**
-- MoQ: Native browser support
-- RTMP/SRT: Requires plugins or native apps
-- MoQ: Modern, layered design
-- RTMP/SRT: Monolithic, dated
-
-## Next Steps
-
-- Read the [Protocol specifications](/guide/protocol)
-- Learn about [Authentication](/guide/authentication)
-- Deploy to production with the [Deployment guide](/guide/deployment)
-- Build with [Rust libraries](/rust/) or [TypeScript libraries](/typescript/)
diff --git a/doc/concepts/authentication.md b/doc/concepts/authentication.md
deleted file mode 100644
index 205f96d6ab..0000000000
--- a/doc/concepts/authentication.md
+++ /dev/null
@@ -1,144 +0,0 @@
-# Authentication
-
-[moq-relay](/rust/moq-relay) uses JWT tokens in the URL for authentication and authorization.
-This scopes sessions to a selected root path with additional rules for publishing and subscribing.
-
-Note that this authentication only applies when using the relay.
-The application is responsible for authentication when using [moq-lite](/rust/moq-lite) directly,
-
-
-## Overview
-
-The authentication system supports:
-- **JWT-based authentication** with query parameter tokens
-- **Path-based authorization** with hierarchical permissions
-- **Symmetric key cryptography** (HMAC-SHA256/384/512)
-- **Asymmetric key cryptography** (RSASSA-PKCS1-SHA256/384/512, RSASSA-PSS-SHA256/384/512, ECDSA-SHA256/384, EdDSA)
-- **Anonymous access** for public content
-- **Cluster authentication** for relay-to-relay communication
-
-## Usage
-
-## Anonymous Access
-If you don't care about security, anonymous access is supported.
-The relay can be configured with a single public prefix, usually "anon".
-This is obviously not recommended in production especially because broadcast paths are not unique and can be hijacked.
-
-**Example URL**: `https://cdn.moq.dev/anon`
-
-**Example Configuration:**
-```toml
-# relay.toml
-[auth]
-public = "anon" # Allow anonymous access to anon/**
-key = "root.jwk" # Require a token for all other paths
-```
-
-If you really, really just don't care, then you can allow all paths.
-
-**Fully Unauthenticated**
-```toml
-# relay.toml
-[auth]
-public = "" # Allow anonymous access to everything
-```
-
-And if you want to require an auth token, you can omit the `public` field entirely.
-**Fully Authenticated**
-```toml
-# relay.toml
-[auth]
-key = "root.jwk" # Require a token for all paths
-```
-
-
-### Authenticated Tokens
-An token can be passed via the `?jwt=` query parameter in the connection URL:
-
-**Example URL**: `https://cdn.moq.dev/demo?jwt=`
-
-**WARNING**: These tokens are only as secure as the delivery.
-Make sure that any secrets are securely transmitted (ex. via HTTPS) and stored (ex. secrets manager).
-Avoid logging this query parameter if possible; we'll switch to an `Authentication` header once WebTransport supports it.
-
-The token contains permissions that apply to the session.
-It can also be used to prevent publishing (read-only) or subscribing (write-only) on a per-path basis.
-
-**Example Token (unsigned)**
-```json
-{
- "root": "room/123", // Root path for all operations
- "pub": "alice", // Publishing permissions (optional)
- "sub": "", // Subscription permissions (optional)
- "cluster": false, // Cluster node flag
- "exp": 1703980800, // Expiration (unix timestamp)
- "iat": 1703977200 // Issued at (unix timestamp)
-}
-```
-
-This token allows:
-- ✅ Connect to `https://cdn.moq.dev/room/123`
-- ❌ Connect to: `https://cdn.moq.dev/secret` (wrong root)
-- ✅ Publish to `alice/camera`
-- ❌ Publish to: `bob/camera` (only alice)
-- ✅ Subscribe to `bob/screen`
-- ❌ Subscribe to: `../secret` (scope enforced)
-
-A token may omit either the `pub` or `sub` field to make a read-only or write-only token respectively.
-An empty string means no restrictions.
-
-Note that there are implicit `/` delimiters added when joining paths (except for empty strings).
-Leading and trailing slashes are ignored within a token.
-
-All subscriptions and announcements are relative to the connection URL.
-These would all resolves to the same broadcast:
-- `CONNECT https://cdn.moq.dev/room/123` could `SUBSCRIBE alice`.
-- `CONNECT https://cdn.moq.dev/room` could `SUBSCRIBE 123/alice`.
-- `CONNECT https://cdn.moq.dev` could `SUBSCRIBE room/123/alice`.
-
-
-The connection URL must contain the root path within the token.
-It's possible use a more specific path, potentially losing permissions in the process.
-
-Our example token from above:
-- 🔴 Connect to `http://cdn.moq.dev/room` (must contain room/123)
-- 🟢 Connect to `http://cdn.moq.dev/room/123`
-- 🟡 Connect to `http://cdn.moq.dev/room/123/alice` (can't subscribe to `bob`)
-- 🟡 Connect to `http://cdn.moq.dev/room/123/bob` (can't publish to `alice`)
-
-
-### Generating Tokens
-
-`moq-token` is available as a Rust crate ([docs.rs](https://docs.rs/moq-token)), JS library ([@moq/token](https://www.npmjs.com/package/@moq/token)), and CLI.
-This documentation focuses on the CLI but the same concepts apply to all.
-
-**Installation**:
-```bash
-# Install the `moq-token` binary
-cargo install moq-token-cli
-```
-
-**Generate a key**:
-```bash
-moq-token --key "root.jwk" generate
-```
-
-**Sign a token**:
-```bash
-moq-token --key "root.jwk" sign \
- --root "rooms/meeting-123" \
- --subscribe "" \
- --publish "alice" \
- --expires 1703980800 > "alice.jwt"
-```
-
-
-And of course, the relay has to be configured with the same key to verify tokens.
-We currently only support symmetric keys.
-
-**Example Configuration:**
-```toml
-# config.toml
-[auth]
-key = "root.jwk" # Path to the key we generated.
-```
diff --git a/doc/concepts/deployment.md b/doc/concepts/deployment.md
deleted file mode 100644
index 028bcd6e78..0000000000
--- a/doc/concepts/deployment.md
+++ /dev/null
@@ -1,409 +0,0 @@
----
-title: Deployment
-description: Deploying MoQ relay to production
----
-
-# Deployment Guide
-
-This guide covers deploying `moq-relay` to production environments.
-
-## Relay Server
-
-The relay server routes broadcasts between publishers and subscribers. It's designed to be simple, stateless, and horizontally scalable.
-
-### Requirements
-
-**Minimum:**
-- 2 CPU cores
-- 2 GB RAM
-- Public IP address
-- UDP port 4443 open (or custom port)
-
-**Recommended for production:**
-- 4+ CPU cores
-- 8+ GB RAM
-- Geographic distribution (multiple regions)
-- Load balancing / GeoDNS
-
-### Installation
-
-#### Using Cargo
-
-```bash
-cargo install moq-relay
-```
-
-#### From Source
-
-```bash
-git clone https://github.com/moq-dev/moq
-cd moq
-cargo build --release --bin moq-relay
-```
-
-The binary will be in `target/release/moq-relay`.
-
-#### Using Nix
-
-```bash
-nix build github:moq-dev/moq#moq-relay
-```
-
-### Configuration
-
-Create a configuration file `relay.toml`:
-
-```toml
-[server]
-bind = "[::]:4443" # Listen on all interfaces, port 4443
-
-[tls]
-cert = "/path/to/cert.pem" # TLS certificate
-key = "/path/to/key.pem" # TLS private key
-
-[auth]
-public = "anon" # Allow anonymous access to anon/**
-key = "root.jwk" # JWT key for authenticated paths
-```
-
-### TLS Certificates
-
-The relay requires TLS certificates. Use [Let's Encrypt](https://letsencrypt.org/):
-
-```bash
-# Install certbot
-sudo apt install certbot # Ubuntu/Debian
-brew install certbot # macOS
-
-# Generate certificate
-sudo certbot certonly --standalone -d relay.example.com
-```
-
-Certificates will be in `/etc/letsencrypt/live/relay.example.com/`.
-
-Update `relay.toml`:
-
-```toml
-[tls]
-cert = "/etc/letsencrypt/live/relay.example.com/fullchain.pem"
-key = "/etc/letsencrypt/live/relay.example.com/privkey.pem"
-```
-
-::: tip
-Set up automatic renewal:
-```bash
-sudo certbot renew --dry-run
-```
-:::
-
-### Running the Relay
-
-```bash
-moq-relay --config relay.toml
-```
-
-#### As a systemd Service
-
-Create `/etc/systemd/system/moq-relay.service`:
-
-```ini
-[Unit]
-Description=MoQ Relay Server
-After=network.target
-
-[Service]
-Type=simple
-User=moq
-WorkingDirectory=/opt/moq
-ExecStart=/usr/local/bin/moq-relay --config /opt/moq/relay.toml
-Restart=always
-RestartSec=10
-
-[Install]
-WantedBy=multi-user.target
-```
-
-Enable and start:
-
-```bash
-sudo systemctl enable moq-relay
-sudo systemctl start moq-relay
-sudo systemctl status moq-relay
-```
-
-View logs:
-
-```bash
-sudo journalctl -u moq-relay -f
-```
-
-## Cloud Deployment
-
-### Linode Example
-
-The repository includes OpenTofu/Terraform configuration for Linode in the `cdn/` directory.
-
-**Features:**
-- Multi-region deployment
-- Automatic clustering
-- GeoDNS via Google Cloud DNS
-- JWT authentication
-
-**Setup:**
-
-1. Copy example configuration:
-```bash
-cd cdn
-cp terraform.tfvars.example terraform.tfvars
-```
-
-2. Fill in your credentials in `terraform.tfvars`
-
-3. Initialize:
-```bash
-tofu init
-```
-
-4. Deploy:
-```bash
-tofu apply
-```
-
-5. Generate secrets:
-```bash
-mkdir -p secrets
-
-# Generate root key
-cargo run --bin moq-token -- --key secrets/root.jwk generate
-
-# Generate cluster token (for relay-to-relay auth)
-cargo run --bin moq-token -- --key secrets/root.jwk sign \
- --publish "" --subscribe "" --cluster > secrets/cluster.jwt
-
-# Generate demo publisher token
-cargo run --bin moq-token -- --key secrets/root.jwk sign \
- --root "demo" --publish "" > secrets/demo-pub.jwt
-```
-
-6. Deploy to all nodes:
-```bash
-just deploy-all
-```
-
-**Monitoring:**
-
-```bash
-# SSH into a node
-just ssh
-
-# View logs
-just logs
-```
-
-**Costs:**
-
-The example configuration uses:
-- 3x `g6-standard-2` relay nodes ($25/month each)
-- 1x `g6-nanode-1` publisher node ($5/month)
-
-Total: $80/month
-
-Adjust node count in `input.tf`.
-
-### Other Cloud Providers
-
-MoQ relay works on any provider with:
-- UDP support
-- Public IP addresses
-- Linux OS
-
-Tested providers:
-- AWS EC2
-- Google Cloud Compute Engine
-- Azure VMs
-- DigitalOcean Droplets
-- Linode
-- Vultr
-- Hetzner
-
-::: warning
-Some providers block or rate-limit UDP. Test thoroughly before production deployment.
-:::
-
-## Clustering
-
-Multiple relay instances can cluster for geographic distribution:
-
-```toml
-# relay.toml
-[cluster]
-nodes = [
- "https://us-east.relay.example.com",
- "https://eu-west.relay.example.com",
- "https://ap-south.relay.example.com"
-]
-```
-
-Each relay connects to others and forwards broadcasts between regions.
-
-**Benefits:**
-- Lower latency (users connect to nearest relay)
-- Higher availability (redundancy)
-- Geographic distribution
-
-**Current limitations:**
-- Mesh topology (all relays connect to all others)
-- Not optimized for large clusters (3-5 nodes recommended)
-
-See [moq-relay README](https://github.com/moq-dev/moq/tree/main/rs/moq-relay) for details.
-
-## Authentication
-
-See the [Authentication guide](/guide/authentication) for JWT setup.
-
-Quick example:
-
-```bash
-# Generate key
-moq-token --key root.jwk generate
-
-# Sign a token
-moq-token --key root.jwk sign \
- --root "rooms/meeting-123" \
- --publish "alice" \
- --subscribe "" \
- --expires 1735689600 > alice.jwt
-```
-
-Configure relay:
-
-```toml
-[auth]
-key = "root.jwk"
-```
-
-Connect with token:
-
-```
-https://relay.example.com/rooms/meeting-123?jwt=
-```
-
-## Monitoring
-
-### Metrics
-
-The relay exposes metrics (Prometheus format planned):
-
-- Connection count
-- Bandwidth usage
-- Track/broadcast count
-- Error rates
-
-### Logging
-
-Structured logging via `tracing`:
-
-```bash
-# Set log level
-RUST_LOG=info moq-relay --config relay.toml
-
-# More verbose
-RUST_LOG=debug moq-relay --config relay.toml
-
-# Specific module
-RUST_LOG=moq_relay=debug moq-relay --config relay.toml
-```
-
-### Health Checks
-
-Check relay health:
-
-```bash
-# Basic connectivity (will fail without valid JWT)
-curl https://relay.example.com/anon
-```
-
-Set up proper health monitoring with your provider's tools (CloudWatch, Stackdriver, etc.).
-
-## Performance
-
-### Current Status
-
-- **Single-threaded** - Quinn uses one UDP receive thread
-- **Mesh clustering** - All relays connect to all others
-- **Memory buffering** - Recent groups cached in memory
-
-### Scaling Tips
-
-1. **Vertical scaling** - Fast CPU matters more than core count
-2. **Regional deployment** - Reduce cross-region traffic
-3. **Limit cluster size** - 3-5 relays optimal
-4. **Monitor bandwidth** - Most important metric
-
-### Future Improvements
-
-Planned optimizations:
-- Multi-threaded UDP processing
-- Tree-based clustering topology
-- Improved memory management
-
-## Security
-
-### Best Practices
-
-1. **Always use TLS** - Required for WebTransport
-2. **Enable authentication** - Use JWT tokens
-3. **Limit public paths** - Minimize anonymous access
-4. **Rotate keys** - Regularly update JWT keys
-5. **Monitor traffic** - Watch for abuse
-6. **Firewall rules** - Restrict to necessary ports
-
-### DDoS Protection
-
-Basic protection:
-
-- Use cloud provider DDoS protection
-- Rate limit connections (application level)
-- Geographic filtering if needed
-- Monitor bandwidth usage
-
-## Troubleshooting
-
-### Certificate Issues
-
-```
-Error: TLS handshake failed
-```
-
-Check:
-- Certificate is valid and not expired
-- Certificate matches domain name
-- Private key permissions are correct
-- Certificate includes full chain
-
-### UDP Blocked
-
-```
-Error: Connection timeout
-```
-
-Check:
-- UDP port 4443 is open in firewall
-- Cloud provider allows UDP traffic
-- No NAT/router blocking
-- Try different port
-
-### High Memory Usage
-
-The relay caches recent groups in memory. If memory usage is high:
-
-- Reduce cache duration (future config option)
-- Increase available RAM
-- Monitor for memory leaks (report if found)
-
-## Next Steps
-
-- Set up [Authentication](/guide/authentication)
-- Understand [Architecture](/guide/architecture)
-- Read [Protocol specifications](/guide/protocol)
-- Build with [Rust](/rust/) or [TypeScript](/typescript/) libraries
diff --git a/doc/concepts/index.md b/doc/concepts/index.md
deleted file mode 100644
index 3446529617..0000000000
--- a/doc/concepts/index.md
+++ /dev/null
@@ -1,270 +0,0 @@
----
-title: Concepts
-description: Understanding MoQ's fundamental concepts and layering
----
-
-# Concepts
-
-MoQ is built on a few simple but powerful concepts.
-Understanding these will help you work effectively with the protocol and build your own applications.
-
-## Layers
-The over-arching design philosophy of MoQ is to make things simple, composable, and customizable.
-We don't want you to hit a brick wall if you deviate from the standard path (*ahem* WebRTC).
-We also want to benefit from economies of scale (like HTTP), utilizing generic libraries and tools whenever possible.
-
-To accomplish this, MoQ is broken into layers:
-
-```
-┌─────────────────┐
-│ Application │ 🏢 Your business logic
-│ │ - authentication, non-media tracks, etc.
-├─────────────────┤
-│ hang │ 🎬 Media-specific encoding/streaming
-│ │ - codecs, containers, catalog
-├─────────────────├
-│ moq-lite │ 🚌 Generic pub/sub transport
-│ │ - broadcasts, tracks, groups, frames
-├─────────────────┤
-│ WebTransport │ 🌐 Browser-compatible QUIC
-│ │ - HTTP/3 handshake
-├─────────────────┤
-| QUIC | 🌐 Underlying transport protocol
-│ │ - streams, datagrams, prioritization, etc.
-└─────────────────┘
-```
-
-You get to choose which layers you want to use and which layers you want to replace.
-
-For example, if you want to implement end-to-end encryption or a custom media format, you can fork the `hang` layer.
-You still benefit from the generic `moq-lite` layer and the mass fanout (and CDN support) it provides.
-If you're feeling generous, you could even contribute your changes, perhaps as a separate layer on top of `moq-lite`.
-
-### Key Rule
-
-**The CDN knows nothing about media.** The relay operates purely on `moq-lite` primitives (broadcasts, tracks, groups, frames) without understanding codecs, keyframes, or media containers.
-
-This design:
-- Keeps the relay simple and generic
-- Enables end-to-end encryption
-- Allows custom media formats
-- Supports non-media use cases (chat, data streams, etc.)
-
-## moq-lite Layer
-
-The core transport protocol provides four primitives:
-
-### Broadcasts
-
-A **broadcast** is a collection of related tracks, similar to a "channel" or "room."
-
-Example: A video conference broadcast might contain:
-- `alice/video` track
-- `alice/audio` track
-- `bob/video` track
-- `bob/audio` track
-- `chat` track
-
-### Tracks
-
-A **track** is a named stream of data split into groups.
-
-Properties:
-- Each track has a unique name within a broadcast
-- Tracks are independent and can be subscribed to separately
-- Tracks are append-only live streams
-
-Example: A `video` track contains sequential groups of video frames.
-
-### Groups
-
-A **group** is a sequential collection of frames, usually aligned with a natural boundary.
-
-For video:
-- Typically starts with a keyframe (I-frame)
-- Contains dependent frames (P-frames, B-frames)
-- Allows viewers to join at group boundaries
-
-Properties:
-- Each group has a sequential ID
-- Frames within a group are ordered
-- Groups are delivered over independent QUIC streams
-
-### Frames
-
-A **frame** is a chunk of data - the smallest unit in MoQ.
-
-Properties:
-- Sized payload of bytes
-- Sequential within a group
-- Can have different priorities
-
-Example: A single video frame, audio packet, or chat message.
-
-## hang Layer
-
-The `hang` protocol adds media-specific structure on top of `moq-lite`:
-
-### Catalog
-
-A special track (usually named `catalog`) contains a JSON description of available tracks:
-
-```json
-{
- "tracks": [
- {
- "name": "video",
- "codec": "avc1.640028",
- "width": 1920,
- "height": 1080
- },
- {
- "name": "audio",
- "codec": "opus",
- "sampleRate": 48000,
- "channels": 2
- }
- ]
-}
-```
-
-This enables:
-- Dynamic track discovery
-- Codec negotiation
-- Quality selection
-
-### Container
-
-Each frame in `hang` consists of:
-- **Timestamp** - Presentation time in microseconds
-- **Codec bitstream** - Raw encoded data
-
-This simple container format works with WebCodecs and other modern media APIs.
-
-## QUIC and WebTransport
-
-### QUIC
-
-The underlying transport protocol providing:
-
-- **Streams** - Independent bidirectional channels
-- **Prioritization** - Send important data first
-- **Partial reliability** - Drop old frames when behind
-- **Multiplexing** - Many streams over one connection
-- **Security** - TLS 1.3 built-in
-
-### WebTransport
-
-A browser API for QUIC:
-
-- Based on HTTP/3 handshake
-- Exposes QUIC streams and datagrams
-- Supported in Chromium-based browsers
-- Polyfill available for other browsers
-
-## Data Flow Example
-
-Here's how a video stream flows through MoQ:
-
-### Publisher Side
-
-1. Capture video from camera or file
-2. Encode using H.264/H.265/VP9/AV1
-3. Split into groups (keyframe + dependent frames)
-4. Wrap each frame with timestamp
-5. Publish to relay using `moq-lite`
-
-### Relay Server
-
-1. Accept connection from publisher
-2. Store recent groups in memory
-3. Accept connections from subscribers
-4. Forward groups to subscribers
-5. Apply prioritization rules
-
-### Subscriber Side
-
-1. Connect to relay
-2. Subscribe to broadcast and tracks
-3. Receive groups as QUIC streams
-4. Extract frames and timestamps
-5. Decode using WebCodecs
-6. Render to video element
-
-## Publishing and Subscribing
-
-### Publishing
-
-```typescript
-import * as Moq from "@moq/lite";
-
-const connection = await Moq.connect("https://relay.moq.dev/anon");
-const broadcast = new Moq.BroadcastProducer();
-const track = broadcast.createTrack("video");
-
-const group = track.appendGroup();
-group.write(frameData);
-group.close();
-
-connection.publish("my-stream", broadcast.consume());
-```
-
-### Subscribing
-
-```typescript
-import * as Moq from "@moq/lite";
-
-const connection = await Moq.connect("https://relay.moq.dev/anon");
-const broadcast = connection.consume("my-stream");
-const track = await broadcast.subscribe("video");
-
-for await (const group of track) {
- for await (const frame of group) {
- // Process frame
- }
-}
-```
-
-## Priority and Reliability
-
-MoQ leverages QUIC's features for optimal delivery:
-
-### Prioritization
-
-- **Keyframes** - Highest priority (can't decode without them)
-- **Recent frames** - Higher priority than old frames
-- **Audio** - Often higher priority than video
-
-### Partial Reliability
-
-When network is congested:
-- Old frames can be skipped/dropped
-- Always send the latest data
-- Maintain real-time latency
-
-This is configured at the group level in `moq-lite`.
-
-## End-to-End Encryption
-
-Since the relay doesn't inspect media:
-
-- Frames can be encrypted end-to-end
-- Relay only sees group/frame structure
-- Application handles key exchange
-- Perfect for private communications
-
-## Non-Media Use Cases
-
-The generic design supports:
-
-- **Text chat** - Each message is a frame
-- **Data streams** - Sensor data, logs, metrics
-- **Collaborative editing** - Operation streams
-- **Gaming** - Player positions, events
-
-## Next Steps
-
-- Dive into the [Architecture](/guide/architecture)
-- Read the [Protocol specs](/guide/protocol)
-- Try the [Rust libraries](/rust/)
-- Try the [TypeScript libraries](/typescript/)
diff --git a/doc/concepts/protocol.md b/doc/concepts/protocol.md
deleted file mode 100644
index fcec99f8a3..0000000000
--- a/doc/concepts/protocol.md
+++ /dev/null
@@ -1,232 +0,0 @@
----
-title: Protocol
-description: MoQ protocol specifications and standards
----
-
-# Protocol Specifications
-
-MoQ consists of two main protocol layers: `moq-lite` (transport) and `hang` (media).
-
-## moq-lite
-
-The core pub/sub transport protocol.
-
-**Specification:** [draft-lcurley-moq-lite](https://moq-dev.github.io/drafts/draft-lcurley-moq-lite.html)
-
-### Overview
-
-moq-lite provides a generic live data transport built on QUIC. It defines:
-
-- **Broadcasts** - Collections of tracks
-- **Tracks** - Named streams split into groups
-- **Groups** - Sequential collections of frames
-- **Frames** - Sized payloads
-
-### Design Goals
-
-1. **Generic** - Works for any live data, not just media
-2. **Simple** - Easy to implement and deploy
-3. **Efficient** - Leverages QUIC features for optimal delivery
-4. **Scalable** - Designed for relay-based fan-out
-
-### Key Features
-
-- **Independent delivery** - Each group is a QUIC stream
-- **Prioritization** - Send important groups first
-- **Partial reliability** - Drop old groups when behind
-- **Discovery** - Announce and subscribe to broadcasts
-- **Deduplication** - Relay deduplicates shared subscriptions
-
-### Message Types
-
-The protocol defines several message types:
-
-- `ANNOUNCE` - Publisher announces a broadcast
-- `SUBSCRIBE` - Subscriber requests a track
-- `SUBSCRIBE_OK` / `SUBSCRIBE_ERROR` - Subscription responses
-- `UNSUBSCRIBE` - Cancel a subscription
-- Stream headers - Metadata for groups and frames
-
-See the [specification](https://moq-dev.github.io/drafts/draft-lcurley-moq-lite.html) for complete protocol details.
-
-## hang
-
-Media-specific encoding/streaming protocol built on moq-lite.
-
-**Specification:** [draft-lcurley-moq-hang](https://moq-dev.github.io/drafts/draft-lcurley-moq-hang.html)
-
-### Overview
-
-hang provides a simple media layer optimized for WebCodecs and modern browsers. It defines:
-
-- **Catalog** - JSON track describing available tracks
-- **Container** - Frame format: timestamp + codec bitstream
-
-### Design Goals
-
-1. **WebCodecs compatible** - Works with browser APIs
-2. **Simple container** - Minimal overhead
-3. **Codec agnostic** - Supports any codec
-4. **Quality selection** - Enable adaptive bitrate
-
-### Catalog Format
-
-The catalog is a special track (usually named `catalog`) containing JSON:
-
-```json
-{
- "version": 1,
- "tracks": [
- {
- "name": "video",
- "kind": "video",
- "codec": "avc1.64002a",
- "width": 1920,
- "height": 1080,
- "framerate": 30,
- "bitrate": 5000000
- },
- {
- "name": "audio",
- "kind": "audio",
- "codec": "opus",
- "sampleRate": 48000,
- "channelConfig": "2",
- "bitrate": 128000
- }
- ]
-}
-```
-
-This enables:
-- Dynamic track discovery
-- Codec negotiation
-- Quality/bitrate selection
-- Alternative tracks (multi-bitrate, multi-language)
-
-### Frame Container
-
-Each frame consists of:
-
-```
-┌─────────────────┐
-│ Timestamp (u64) │ Presentation time in microseconds
-├─────────────────┤
-│ Codec Bitstream │ Raw encoded data
-└─────────────────┘
-```
-
-This simple format:
-- Works directly with WebCodecs
-- Minimal parsing overhead
-- Codec-agnostic
-- Supports any timestamp base
-
-### Supported Codecs
-
-**Video:**
-- H.264 (AVC)
-- H.265 (HEVC)
-- VP8
-- VP9
-- AV1
-
-**Audio:**
-- Opus
-- AAC
-- MP3
-
-The codec string follows [RFC 6381](https://tools.ietf.org/html/rfc6381) format for compatibility with WebCodecs.
-
-## Use Cases
-
-**Specification:** [draft-lcurley-moq-use-cases](https://moq-dev.github.io/drafts/draft-lcurley-moq-use-cases.html)
-
-This document describes various use cases and how to implement them with MoQ:
-
-- Live video streaming
-- Audio conferencing
-- Screen sharing
-- Text chat
-- Gaming
-- IoT/telemetry
-- Collaborative editing
-
-## Relationship to IETF MoQ
-
-This project is a [fork](https://moq.dev/blog/transfork) of the [IETF MoQ Working Group](https://datatracker.ietf.org/group/moq/documents/) specification.
-
-### Key Differences
-
-**Scope:**
-- IETF MoQ: Broad, general-purpose media transport
-- This project: Narrower focus on deployability and simplicity
-
-**Design:**
-- IETF MoQ: Feature-rich with many optional extensions
-- This project: Minimal, opinionated design
-
-**Status:**
-- IETF MoQ: Ongoing standardization process
-- This project: Production-ready implementation
-
-### Why Fork?
-
-The fork prioritizes:
-1. **Simplicity** - Fewer concepts, easier to implement
-2. **Deployability** - Can deploy today with existing infrastructure
-3. **Focus** - Optimized for live streaming use cases
-
-Both efforts share knowledge and collaborate where beneficial.
-
-## Protocol Implementation
-
-### Rust
-
-The Rust implementation is the reference:
-
-- **moq-lite** - [crates.io](https://crates.io/crates/moq-lite) | [docs.rs](https://docs.rs/moq-lite)
-- **hang** - [crates.io](https://crates.io/crates/hang) | [docs.rs](https://docs.rs/hang)
-
-See [Rust libraries](/rust/) for details.
-
-### TypeScript
-
-The TypeScript implementation for browsers:
-
-- **@moq/lite** - [npm](https://www.npmjs.com/package/@moq/lite)
-- **@moq/hang** - [npm](https://www.npmjs.com/package/@moq/hang)
-
-See [TypeScript libraries](/typescript/) for details.
-
-### C Bindings
-
-C bindings via FFI:
-
-- **libmoq** - [docs.rs](https://docs.rs/libmoq)
-
-## Protocol Evolution
-
-The protocol is actively developed but aims for stability:
-
-- **moq-lite** - Core is stable, extensions possible
-- **hang** - Open to codec and container improvements
-
-Changes follow semantic versioning with backwards compatibility where possible.
-
-## Contributing to Specifications
-
-Specifications are maintained in the [moq-dev/drafts](https://github.com/moq-dev/drafts) repository.
-
-Contributions welcome:
-- Issue feedback and suggestions
-- Propose clarifications
-- Submit use cases
-- Report implementation issues
-
-## Next Steps
-
-- Understand the [Architecture](/guide/architecture)
-- Learn about [Authentication](/guide/authentication)
-- Try the [Rust libraries](/rust/) or [TypeScript libraries](/typescript/)
-- Deploy with the [Deployment guide](/guide/deployment)
diff --git a/doc/demo/index.md b/doc/demo/index.md
new file mode 100644
index 0000000000..e319ce0ad1
--- /dev/null
+++ b/doc/demo/index.md
@@ -0,0 +1,16 @@
+---
+title: Demos
+description: Interactive demos showing off what MoQ can do
+---
+
+# Demos
+
+Small apps that each exercise a different part of the MoQ protocol. Run locally with `just demo `, or click through to the live version at [moq.dev](https://moq.dev/demo).
+
+## Available Demos
+
+| Demo | Description | Live |
+|------|-------------|------|
+| [MoQ Boy](/demo/moq-boy) | Crowd-controlled Game Boy Color emulator. On-demand encoding, prefix-based discovery, bidirectional input. | [moq.dev/boy](https://moq.dev/boy) |
+
+For setup instructions (running these locally, custom ROMs, etc.), see the [setup guide](/setup/).
diff --git a/doc/demo/moq-boy.md b/doc/demo/moq-boy.md
new file mode 100644
index 0000000000..3322bb7a5c
--- /dev/null
+++ b/doc/demo/moq-boy.md
@@ -0,0 +1,85 @@
+---
+title: MoQ Boy
+description: Architecture and track layout of the MoQ Boy demo
+---
+
+# MoQ Boy
+
+A crowd-controlled Game Boy Color emulator that streams over MoQ. The server emulates and encodes on-demand, the browser subscribes to whichever games are visible, and any viewer can send inputs back. Live at [moq.dev/boy](https://moq.dev/boy); local setup instructions are in the [setup guide](/setup/demo/boy).
+
+This page documents what the demo demonstrates and how its tracks are wired together. For the running-locally instructions see [setup/demo/boy](/setup/demo/boy); for the code see the [moq-boy](/lib/rs/crate/moq-boy) crate and the [@moq/boy](/lib/js/@moq/boy) package.
+
+## What it demonstrates
+
+- **On-demand encoding** — emulation and encoding only run while at least one viewer is subscribed. Scroll the video off-screen and the server pauses within a frame or two. A fresh keyframe is sent on resume.
+- **Prefix-based discovery** — no control plane. Games and players are discovered by subscribing to announcement prefixes.
+- **Bidirectional communication** — viewers publish a tiny track of their own so the emulator can receive button presses. Same protocol, same relay, no extra plumbing.
+- **Shared input** — every viewer sees which buttons are being held down, by whom, with live latency numbers.
+- **Low-latency A/V** — native Game Boy resolution (160x144) at ~60fps with sub-frame latency on a LAN.
+
+## Broadcast layout
+
+Two prefixes: one for the emulator, one for viewers.
+
+```text
+{gamePrefix}/
+ {name}/ <- one session per running emulator
+ catalog.json <- video + audio renditions (managed by moq-mux)
+ video0.avc3 <- 160x144 H.264 at ~60fps
+ audio0.opus <- Opus audio
+ status <- JSON state (raw moq-lite track)
+
+{viewerPrefix}/
+ {name}/
+ {viewerId}/ <- one broadcast per connected viewer
+ command <- JSON commands (raw moq-lite track)
+```
+
+| Environment | Game prefix | Viewer prefix |
+|-------------|-------------|---------------|
+| **Localhost** | `anon/boy/game` | `anon/boy/viewer` |
+| **Production** | `demo/boy/game` (authenticated) | `anon/boy/viewer` (unauthenticated) |
+
+Splitting authenticated and unauthenticated traffic lets the server be the only thing that can publish a game, while anyone can show up and play.
+
+## Media tracks
+
+The video and audio tracks go through [moq-mux](/lib/rs/crate/moq-mux), which handles the [hang](/lib/rs/crate/hang) container format (catalog, codec init, group boundaries).
+
+| Track | Codec | Resolution | Framerate | Pipeline |
+|-------|-------|-----------|-----------|----------|
+| `video0.avc3` | H.264 (avc3) | 160x144 | ~60fps | RGBA → YUV → encode via `ffmpeg-next` |
+| `audio0.opus` | Opus | — | — | APU PCM → encode via `ffmpeg-next` → `moq_mux::import::Opus` |
+
+The browser upscales video with CSS `image-rendering: pixelated` so the pixel art stays crisp.
+
+## Metadata tracks
+
+The `status` and `command` tracks bypass the container format — they're raw UTF-8 JSON bytes written directly to [moq-net](/lib/rs/crate/moq-net) groups.
+
+| Track | Direction | Format |
+|-------|-----------|--------|
+| `status` | Server → viewers | `{"buttons": ["up", "a"], "latency": {"abc123": 42}}` |
+| `command` | Viewer → server | `{"type": "buttons", "buttons": ["left"]}` or `{"type": "reset"}` |
+
+Every viewer subscribes to the server's `status` track so they see shared input. The server subscribes to the viewer prefix via `OriginProducer::with_root()` and fans in all `command` tracks, so adding a new viewer is just a new announcement.
+
+## Auto-pause
+
+When the last viewer unsubscribes from a session's video track, the emulator stops advancing the CPU and the encoder stops running. On the next subscribe, emulation resumes and a keyframe is pushed immediately.
+
+This works because subscriptions are a first-class signal in MoQ — the emulator doesn't need heuristics or timers, it just watches whether anyone is consuming the track.
+
+## Source code
+
+- **Rust emulator/publisher**: [rs/moq-boy](https://github.com/moq-dev/moq/tree/main/rs/moq-boy) — emulator loop, video/audio encoding, input handling
+- **Browser player**: [js/moq-boy](https://github.com/moq-dev/moq/tree/main/js/moq-boy) — discovery, rendering, input capture, UI
+- **Demo wiring**: [demo/boy](https://github.com/moq-dev/moq/tree/main/demo/boy) — the HTML page that mounts ``
+
+## Related
+
+- [moq-boy crate](/lib/rs/crate/moq-boy) — the Rust binary
+- [@moq/boy package](/lib/js/@moq/boy) — the browser player
+- [setup/demo/boy](/setup/demo/boy) — how to run it locally, controls, custom ROMs
+- [moq-net](/lib/rs/crate/moq-net) — the networking layer used by every track here
+- [moq-mux](/lib/rs/crate/moq-mux) — the container format used for A/V
diff --git a/doc/index.md b/doc/index.md
index eb9d15333d..c9cfdbde99 100644
--- a/doc/index.md
+++ b/doc/index.md
@@ -8,72 +8,134 @@ hero:
link: /setup/
- theme: alt
text: Concepts
- link: /concepts/
+ link: /concept/
- theme: alt
- text: API
- link: /api/
+ text: Apps
+ link: /bin/
+ - theme: alt
+ text: Libraries
+ link: /lib/
- theme: alt
text: Demo
link: https://moq.dev/
features:
- - icon: 🚀
- title: Real-time Latency
- details: MoQ supports the entire latency spectrum, down to the tens of milliseconds. All thanks to QUIC.
-
- - icon: 📈
- title: Massive Scale
- details: Everything is designed to fan-out across a generic CDN. Able to handle millions of concurrent viewers across the globe.
-
- - icon: 🌐
+ - icon:
+ src: /emoji/rocket.svg
+ title: Adaptive
+ details: MoQ supports the entire latency spectrum. Simultaneously support real-time, interactive, or lean-back experiences with a unified stack.
+
+ - icon:
+ src: /emoji/stonk.svg
+ title: Scalable
+ details: All content can be cached and fanned-out via a CDN. Serve millions of concurrent viewers across the globe, including via Cloudflare.
+
+ - icon:
+ src: /emoji/puzzle.svg
+ title: Extensible
+ details: Supports contribution, distribution, conferencing, and whatever you can dream up. Extend the protocol with custom tracks for any live content.
+
+ - icon:
+ src: /emoji/globe.svg
title: Modern Web
- details: Uses WebTransport, WebCodecs, and WebAudio APIs for native browser compatibility without hacks.
-
- - icon: 🎯
- title: Multi-platform
- details: Implemented in Rust (native) and TypeScript (web). Comes with integrations for ffmpeg, OBS, Gstreamer, and more to come.
+ details: Utilizes WebTransport, WebCodecs, and WebAudio APIs for modern browser support without hacks.
- - icon: 🔧
- title: Generic Protocol
- details: Not just for media; MoQ is able to deliver any live or custom data. Your application is in control.
+ - icon:
+ src: /emoji/box.svg
+ title: Cross-Platform
+ details: Libraries for Rust (native) and TypeScript (web), plus FFI bindings for C, Python, Kotlin, Swift, and Go. Integrations with ffmpeg, OBS, GStreamer, and more to come.
- - icon: 💪
+ - icon:
+ src: /emoji/battery.svg
title: Efficient
- details: Save resources by only encoding or transmitting data when needed. Built on top of production-grade QUIC libraries.
+ details: Save resources by only encoding or transmitting data when needed. Built on top of production-ready QUIC libraries.
+
+ - icon:
+ src: /emoji/lock.svg
+ title: Secure
+ details: Encrypted via TLS and authenticated via JWT. You can optionally self-host a private CDN or end-to-end encrypt your content.
+
+ - icon:
+ src: /emoji/back.svg
+ title: Backwards Compatible
+ details: Supports CMAF and HLS for legacy device support. Migrate legacy devices at your own pace.
+
+ - icon:
+ src: /emoji/link.svg
+ title: Decentralized
+ details: Host your own CDN, use a 3rd party service, and/or connect P2P via Iroh (native only). Broadcasts are automatically discovered and gossiped.
---
## What is MoQ?
-[Media over QUIC](https://moq.dev) (MoQ) is a next-generation live media protocol that provides **real-time latency** at **massive scale**. Built using modern web technologies, MoQ delivers WebRTC-like latency without the constraints of WebRTC. The core networking is delegated to a QUIC library but the rest is in application-space, giving you full control over your media pipeline.
+**Media over QUIC** (MoQ) is a next-generation live media protocol.
+As the name implies, we use QUIC to concurrently transmit media and avoid latency build-up during congestion.
+The protocol is being standardized by the [IETF](https://datatracker.ietf.org/group/moq/about/) and backed by some of the largest tech companies: Google, Cisco, Akamai, Cloudflare, etc.
-**NOTE**: This project uses [moq-lite](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/) and [hang](https://datatracker.ietf.org/doc/draft-lcurley-moq-hang/) instead of the *official* [IETF drafts](https://datatracker.ietf.org/group/moq/documents/).
-The focus is on simplicity and deployability, avoiding the bloat and politics experimental protocols designed by committee.
-We support compatibility with a subset of the latest IETF drafts, but it's *not recommended* given the ongoing standardization churn.
+[moq.dev](https://moq.dev) is an open source implementation written in Rust (native) and Typescript (web).
+We support compatibility with the *official* [IETF drafts](https://datatracker.ietf.org/group/moq/documents/), but the main focus is a subset called [moq-lite](/concept/layer/moq-lite) and [hang](/concept/layer/hang).
+The idea is to [build first, argue later](/concept/standard/).
-## Quick Start
+See the [concepts](/concept/) page for a breakdown of the layering, rationale, and comparison to other protocols.
-Get up and running in seconds with [Nix](https://nixos.org/download.html), or use an [alternative method](/setup).
+## Setup
+
+Get up and running in seconds with [Nix](https://nixos.org/download.html) ([+Flakes](https://nixos.wiki/wiki/Flakes)), or be lame and [install stuff manually](/setup/):
```bash
# Runs a relay, media publisher, and the web server
-nix develop -c just dev
+nix develop -c just
```
-## Rust
-The Rust libraries are intended for native platforms, such as desktop applications or servers.
+If everything works, a browser window will pop up demoing how to both publish and watch content via the web.
+
+- Keep reading the [development guide](/setup/dev) to run more advanced demos.
+- Skip ahead to the [production guide](/setup/prod) to see what it takes to deploy this bad boy.
+
+## Applications
+
+There are a bunch of MoQ binaries and plugins.
+
+Some highlights:
+
+- [moq-relay](/bin/relay/) - A server connecting publishers to subscribers, able to form a [self-hosted CDN cluster](/bin/relay/cluster).
+- [moq-cli](/bin/cli) - A CLI that can import and publish MoQ broadcasts from a variety of formats (fMP4, HLS, etc), including via ffmpeg.
+- [obs](/bin/obs) - An OBS plugin, able to publish a MoQ broadcast and/or use MoQ broadcasts as sources.
+- [gstreamer](/bin/gstreamer) - A gstreamer plugin, split into a source and a sink.
+- [web](/bin/web) - A web component you can slap on your website to watch and publish MoQ broadcasts.
+- [...and more](/bin/)
+
+## Rust Crates 🦀
+
+Integrate MoQ into your application without fear. Focused on [native](/lib/rs/env/native) but has token [WASM](/lib/rs/env/wasm) support.
+
+Some highlights:
+
+- [moq-net](/lib/rs/crate/moq-net) - Real-time pub/sub with built-in caching, fan-out, and prioritization.
+- [moq-mux](/lib/rs/crate/moq-mux) - Media muxers/demuxers for fMP4, CMAF, and HLS import.
+- [libmoq](/lib/rs/crate/libmoq) - C bindings for the above, no finagling Rust into your build system.
+- [web-transport](/lib/rs/crate/web-transport) - A suite of crates required to get QUIC access in the browser, plus some polyfills.
+- [...and more](/lib/rs/)
+
+## TypeScript Packages
+
+Run MoQ in a [web browser](/lib/js/env/web) utilizing the latest Web tech.
+Or run on [native](/lib/js/env/native) with polyfills via Node/Bun/Deno.
-- **[moq-lite](/rust/moq-lite)** - The core pub/sub transport protocol; media agnostic.
-- **[moq-relay](/rust/moq-relay)** - A clusterable relay server that can form a CDN.
-- **[hang](/rust/hang)** - The media library: provides codecs, containers, etc.
-- **[hang-cli](/rust/hang-cli)** - A CLI tool for publishing media from a variety of sources.
+Some highlights:
-[Full Rust Documentation →](/rust/)
+- [@moq/net](/lib/js/@moq/net) - Real-time pub/sub with built-in caching, fan-out, and prioritization.
+- [@moq/hang](/lib/js/@moq/hang/) - Performs any media stuff: capture, encode, transmux, decode, render.
+- [@moq/watch](/lib/js/@moq/watch) - Subscribe to and render MoQ broadcasts.
+- [@moq/publish](/lib/js/@moq/publish) - Publish media to MoQ broadcasts.
+- [...and more](/lib/js/)
-## TypeScript
-The TypeScript libraries are intended for web browsers, but also work on [Deno](https://deno.com/) with its experimental WebTransport support.
+## Other Languages
-- **[@moq/lite](/typescript/lite)** - The core pub/sub transport protocol; media agnostic.
-- **[@moq/hang](/typescript/hang)** - The media library; provides codecs, containers, etc.
-- **[@moq/hang-ui](https://www.npmjs.com/package/@moq/hang-ui)** - Optional UI controls layered on top of `@moq/hang`.
+FFI bindings around the Rust core, with idiomatic APIs in each language:
-[Full TypeScript Documentation →](/typescript/)
+- [C](/lib/c/) - `libmoq` static + shared library with an auto-generated header.
+- [Python](/lib/py/) - `asyncio`-friendly bindings, published to PyPI.
+- [Kotlin](/lib/kt/) - Coroutines and `Flow` for Android and the JVM.
+- [Swift](/lib/swift/) - Async sequences for iOS, iPadOS, and macOS.
+- [Go](/lib/go/) - cgo bindings resolved via `go get`.
diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md
new file mode 100644
index 0000000000..2cb8f3a06f
--- /dev/null
+++ b/doc/lib/c/index.md
@@ -0,0 +1,66 @@
+---
+title: C Libraries
+description: C bindings for Media over QUIC via libmoq
+---
+
+# C Libraries
+
+The C bindings expose [Media over QUIC](/) to C and C++ applications. Built on top of the Rust [`moq-net`](/lib/rs/crate/moq-net) crate via FFI, with no Rust toolchain required at link time.
+
+## Libraries
+
+### libmoq
+
+[](https://docs.rs/libmoq)
+
+A C-callable shared and static library exposing the MoQ pub/sub API. Header files are generated by `cbindgen` and ship alongside the prebuilt binaries.
+
+**Features:**
+
+- Static (`libmoq.a`) and dynamic (`libmoq.so` / `libmoq.dylib` / `moq.dll`) library targets
+- Auto-generated C header (`moq.h`)
+- No Rust runtime exposed to consumers
+- Works with any toolchain that can link a C library (CMake, Make, Meson, etc.)
+
+[Learn more](/lib/rs/crate/libmoq)
+
+## Installation
+
+### From source
+
+```bash
+git clone https://github.com/moq-dev/moq
+cd moq/rs/libmoq
+cargo build --release
+```
+
+The library will be in `target/release/libmoq.a` (static) and `target/release/libmoq.{so,dylib,dll}` (dynamic). The C header is emitted as `target/release/moq.h`.
+
+### From prebuilt releases
+
+Prebuilt binaries are attached to each [`libmoq-v*` release](https://github.com/moq-dev/moq/releases) for the major Tier 1 targets.
+
+## Callback lifetime
+
+Any function that registers a callback (`moq_session_connect`, `moq_origin_announced`, `moq_consume_catalog`, `moq_consume_video_ordered`, `moq_consume_audio_ordered`, `moq_consume_track`, `moq_consume_audio_raw`) takes a `void *user_data` pointer that libmoq passes back to every callback invocation. The status code carries the lifecycle:
+
+- **`> 0`** — a live result you can use: a frame, catalog, or announce ID (or `1` to mean "session connected"). May fire any number of times.
+- **`0`** — closed cleanly. **Terminal.**
+- **`< 0`** — closed with an error. **Terminal.**
+
+Once a callback fires with any non-positive (`<= 0`) code, libmoq will never invoke it again and never touch `user_data` again. Release `user_data` in response to that final callback.
+
+The matching `*_close` function only *requests* shutdown: it returns immediately, does **not** free `user_data`, and does **not** cancel the final callback. The terminal callback still fires (on libmoq's internal thread) once the background task stops, and that is the one safe point to free `user_data`. This means you never have to guess whether an in-flight callback is still running after `close`, and you don't need an external weak-reference or refcount around `user_data`.
+
+Because the terminal callback runs on libmoq's thread, bindings that own thread-affine objects (e.g. a Qt `QObject`) should hop to the owning thread to perform the actual destruction; the `user_data` lifetime contract holds regardless of which thread tears the object down.
+
+## Use cases
+
+- **C/C++ applications** integrating MoQ without a Rust toolchain
+- **Bindings for other languages** that aren't already covered by [Python](/lib/py/), [Kotlin](/lib/kt/), [Swift](/lib/swift/), or [Go](/lib/go/)
+- **Legacy systems** and **embedded** targets where pulling in Rust at build time is impractical
+
+## Source and issues
+
+- Source: [rs/libmoq/](https://github.com/moq-dev/moq/tree/main/rs/libmoq)
+- API reference: [docs.rs/libmoq](https://docs.rs/libmoq)
diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md
new file mode 100644
index 0000000000..2b662ad858
--- /dev/null
+++ b/doc/lib/go/index.md
@@ -0,0 +1,42 @@
+---
+title: Go Libraries
+description: Go module for Media over QUIC
+---
+
+# Go Libraries
+
+The Go bindings expose [Media over QUIC](/) to Go applications via cgo. Built on the same Rust core ([moq-ffi](https://crates.io/crates/moq-ffi)) as the Python, Kotlin, and Swift packages, generated with [uniffi-bindgen-go](https://github.com/NordSecurity/uniffi-bindgen-go).
+
+## Packages
+
+### moq
+
+A single Go module that exposes the UniFFI surface as ordinary Go types. The module ships prebuilt `libmoq_ffi.a` per supported platform and links statically through cgo, so consumers don't need a Rust toolchain or a runtime shared library on their path.
+
+**Supported platforms:**
+
+- `linux/amd64`, `linux/arm64`
+- `darwin/amd64`, `darwin/arm64`
+- `windows/amd64`
+
+[Learn more](/lib/go/moq)
+
+## Installation
+
+The module lives in [moq-dev/moq-go](https://github.com/moq-dev/moq-go), a mirror repo populated by CI on every `moq-ffi-v*` tag.
+
+```bash
+go get github.com/moq-dev/moq-go@v0.2.11
+```
+
+```go
+import "github.com/moq-dev/moq-go/moq"
+```
+
+cgo picks the right `libmoq_ffi.a` automatically via build tags; no `LD_LIBRARY_PATH` or extra setup required. Building requires `CGO_ENABLED=1` (the default on Unix).
+
+## Source and issues
+
+- Source: [go/](https://github.com/moq-dev/moq/tree/main/go) (in the monorepo)
+- Mirror (what `go get` resolves): [moq-dev/moq-go](https://github.com/moq-dev/moq-go)
+- README: [go/README.md](https://github.com/moq-dev/moq/blob/main/go/README.md)
diff --git a/doc/lib/go/moq.md b/doc/lib/go/moq.md
new file mode 100644
index 0000000000..2973c4ebdf
--- /dev/null
+++ b/doc/lib/go/moq.md
@@ -0,0 +1,45 @@
+---
+title: moq (Go)
+description: Go module for Media over QUIC
+---
+
+# moq
+
+The Go module for [Media over QUIC](/).
+
+A thin wrapper around the UniFFI-generated bindings, exposing the same `MoqClient`, `MoqSession`, `MoqBroadcastProducer`, etc. types as the Python, Kotlin, and Swift packages.
+
+## Install
+
+```bash
+go get github.com/moq-dev/moq-go@v0.2.11
+```
+
+```go
+import "github.com/moq-dev/moq-go/moq"
+```
+
+The module bundles prebuilt `libmoq_ffi.a` for `linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, and `windows/amd64`. cgo selects the right archive at link time via build tags.
+
+## Local development
+
+The in-tree `go/` directory is the source skeleton; it's not a buildable Go module on its own (the generated `moq.go` and per-platform `.a` files are added at release time by CI, not committed). To exercise it locally:
+
+```bash
+just go check
+```
+
+This runs `go/scripts/check.sh`, which builds `moq-ffi` for the host arch, regenerates the bindings with `uniffi-bindgen-go`, stages everything into the workspace's `dist/` working dir, and runs `go vet`/`go build`/`go test` from the staged copy. Requires `cargo`, `go`, and `uniffi-bindgen-go` on the path. Install the latter once:
+
+```bash
+cargo install uniffi-bindgen-go \
+ --git https://github.com/NordSecurity/uniffi-bindgen-go \
+ --tag v0.7.1+v0.31.0
+```
+
+## See also
+
+- Source: [go/moq](https://github.com/moq-dev/moq/tree/main/go/moq)
+- Mirror repo: [moq-dev/moq-go](https://github.com/moq-dev/moq-go)
+- The Rust crates this wraps: [moq-net](/lib/rs/crate/moq-net) + [moq-mux](/lib/rs/crate/moq-mux)
+- Shared FFI layer (also powers the Python, Kotlin, and Swift bindings): [moq-ffi](https://crates.io/crates/moq-ffi)
diff --git a/doc/lib/index.md b/doc/lib/index.md
new file mode 100644
index 0000000000..26c01e3f2b
--- /dev/null
+++ b/doc/lib/index.md
@@ -0,0 +1,66 @@
+---
+title: Libraries
+description: MoQ libraries for Rust, TypeScript, C, Python, Kotlin, Swift, and Go
+---
+
+# Libraries
+
+MoQ ships libraries in a handful of languages. **Rust** (native) and **TypeScript** (web) are the primary implementations; everything else wraps the Rust core under the hood.
+
+## Primary
+
+### [Rust](/lib/rs/)
+
+The reference implementation. Used by every server-side tool and by the FFI core that the other language bindings link against.
+
+- [`moq-net`](/lib/rs/crate/moq-net) - Real-time pub/sub
+- [`hang`](/lib/rs/crate/hang) - Media catalog and container
+- [`moq-mux`](/lib/rs/crate/moq-mux) - fMP4/CMAF/HLS import
+- [`moq-native`](/lib/rs/crate/moq-native) - QUIC endpoint helpers
+- [...and more](/lib/rs/)
+
+### [TypeScript](/lib/js/)
+
+The browser implementation. Uses [WebTransport](/concept/layer/web-transport), WebCodecs, and WebAudio to run MoQ natively in the browser without polyfills (in supported browsers).
+
+- [`@moq/net`](/lib/js/@moq/net) - Real-time pub/sub
+- [`@moq/hang`](/lib/js/@moq/hang/) - Media library
+- [`@moq/watch`](/lib/js/@moq/watch) - Subscribe + render
+- [`@moq/publish`](/lib/js/@moq/publish) - Capture + publish
+- [...and more](/lib/js/)
+
+## FFI bindings
+
+These all link against the same [Rust core](https://crates.io/crates/moq-ffi) (via [`libmoq`](/lib/rs/crate/libmoq) + UniFFI) and present an idiomatic API in their host language.
+
+### [C](/lib/c/)
+
+Raw C bindings via `libmoq`. The lowest-level entry point and the foundation for every other binding listed below.
+
+### [Python](/lib/py/)
+
+Async/await with `asyncio`. Published as [`moq-rs`](https://pypi.org/project/moq-rs/) on PyPI (the ergonomic wrapper, imported as `moq`), atop the raw [`moq-ffi`](https://pypi.org/project/moq-ffi/) bindings.
+
+### [Kotlin](/lib/kt/)
+
+Coroutines and `Flow` for Android and the JVM. Published as `dev.moq:moq` on Maven Central.
+
+### [Swift](/lib/swift/)
+
+Async sequences and structured concurrency for iOS, iPadOS, and macOS. Distributed via Swift Package Manager.
+
+### [Go](/lib/go/)
+
+cgo bindings with prebuilt static libraries per platform. Resolved via `go get github.com/moq-dev/moq-go`.
+
+## Picking a language
+
+- **Server, CLI, or anything native** → [Rust](/lib/rs/)
+- **Web browser or Node/Bun/Deno** → [TypeScript](/lib/js/)
+- **iOS / macOS app** → [Swift](/lib/swift/)
+- **Android app or JVM service** → [Kotlin](/lib/kt/)
+- **Scripts, ML pipelines, prototypes** → [Python](/lib/py/)
+- **Go service or tooling** → [Go](/lib/go/)
+- **Anything else with a C ABI** → [C](/lib/c/)
+
+All FFI bindings expose the same protocol surface as the Rust core, so a publisher in Python can be consumed by a Swift subscriber, etc.
diff --git a/doc/lib/js/@moq/boy.md b/doc/lib/js/@moq/boy.md
new file mode 100644
index 0000000000..fa5998cb2b
--- /dev/null
+++ b/doc/lib/js/@moq/boy.md
@@ -0,0 +1,63 @@
+---
+title: "@moq/boy"
+description: Browser player for the MoQ Boy demo
+---
+
+# @moq/boy
+
+[](https://www.npmjs.com/package/@moq/boy)
+[](https://www.typescriptlang.org/)
+
+Browser-side player for the [MoQ Boy demo](https://moq.dev/boy). Discovers active game sessions over MoQ, renders their video/audio, and publishes button input back to the emulator.
+
+The server-side emulator lives in the [moq-boy](/lib/rs/crate/moq-boy) Rust crate.
+
+## Installation
+
+```bash
+bun add @moq/boy
+# or
+npm add @moq/boy
+```
+
+## Web Component
+
+```html
+
+
+
+
+```
+
+**Attributes:**
+
+- `url` (required) — Relay server URL
+- `prefix` — Base path prefix (default: `boy`). Derives `prefix-game` and `prefix-viewer`.
+- `prefix-game` — Path prefix for game broadcasts (default: `{prefix}/game`)
+- `prefix-viewer` — Path prefix for viewer broadcasts (default: `{prefix}/viewer`)
+
+The element manages its own UI (game grid, per-game canvas, on-screen buttons, stats) inside a Shadow DOM.
+
+## How it works
+
+- **Discover games** — subscribes to announcements under `prefix-game` and mounts each suffix as a `Game`.
+- **Subscribe on demand** — video and audio tracks are only subscribed while the game is visible in the viewport, so the server-side emulator auto-pauses when nothing is being watched.
+- **Publish input** — button presses go out on a JSON track under `prefix-viewer/{name}/{viewerId}/command`.
+- **Live status** — the server publishes a `status` track with currently pressed buttons and per-viewer latency, used to highlight shared input.
+
+See [demo/moq-boy](/demo/moq-boy) for the full architecture and track layout.
+
+## Related Packages
+
+- **[@moq/watch](/lib/js/@moq/watch)** — Used under the hood for video/audio playback
+- **[@moq/net](/lib/js/@moq/net)** — Core pub/sub transport
+- **[moq-boy](/lib/rs/crate/moq-boy)** — The Rust emulator/publisher
+
+## Source
+
+[js/moq-boy](https://github.com/moq-dev/moq/tree/main/js/moq-boy)
diff --git a/doc/lib/js/@moq/demo.md b/doc/lib/js/@moq/demo.md
new file mode 100644
index 0000000000..e96c117de7
--- /dev/null
+++ b/doc/lib/js/@moq/demo.md
@@ -0,0 +1,48 @@
+---
+title: "@moq/demo"
+description: Demo application showcasing MoQ in the browser
+---
+
+# @moq/demo
+
+A demo web application that showcases MoQ capabilities.
+Watch live streams, publish from your camera or screen, and explore the technology.
+
+## Setup
+
+Follow the [Quick Start](/setup/) guide to get started.
+
+You can target a remote relay instead of a local one with the command:
+
+```bash
+just web https://cdn.moq.dev/anon
+```
+
+## Watch Demo
+
+Subscribe to a live stream and play it back with adjustable latency.
+The demo connects to a relay and renders video using WebCodecs.
+
+**Features demonstrated:**
+
+- WebTransport/WebSocket connection to relay
+- Track subscription and group delivery
+- WebCodecs decoding
+- Latency measurement and adjustment
+
+## Watch Demo (MSE)
+
+The same thing as above but using MSE (Media Source Extensions) instead of WebCodecs.
+The latency will be a bit higher but it'll work on more devices.
+
+## Publish Demo
+
+Stream your camera, microphone, or screen to the relay.
+Other viewers can watch in real-time... if you publish to a remote relay.
+
+**Features demonstrated:**
+
+- MediaStream capture (getUserMedia, getDisplayMedia)
+- WebCodecs encoding (H.264, H.265, VP8, VP9, AV1, Opus, etc)
+- Catalog generation
+- Track publishing
diff --git a/doc/lib/js/@moq/hang/index.md b/doc/lib/js/@moq/hang/index.md
new file mode 100644
index 0000000000..ab6af897fe
--- /dev/null
+++ b/doc/lib/js/@moq/hang/index.md
@@ -0,0 +1,72 @@
+---
+title: "@moq/hang"
+description: Core media library (catalog, container, support)
+---
+
+# @moq/hang
+
+[](https://www.npmjs.com/package/@moq/hang)
+[](https://www.typescriptlang.org/)
+
+Core media library for [Media over QUIC](https://moq.dev), built on top of [@moq/net](/lib/js/@moq/net). Provides shared primitives used by [`@moq/watch`](/lib/js/@moq/watch) and [`@moq/publish`](/lib/js/@moq/publish).
+
+## Overview
+
+`@moq/hang` provides:
+
+- **Catalog** - JSON track describing other tracks and their codec properties (audio, video, chat, location, etc.)
+- **Container** - Media framing in two formats: CMAF (fMP4) and Legacy (varint-timestamp + raw codec bitstream)
+- **Utilities** - Hex encoding, Opus audio polyfill (libav), latency computation, browser detection workarounds
+
+Browser support detection is provided by [``](/lib/js/@moq/watch) and [``](/lib/js/@moq/publish).
+
+## Installation
+
+```bash
+bun add @moq/hang
+# or
+npm add @moq/hang
+pnpm add @moq/hang
+```
+
+## JavaScript API
+
+```typescript
+import * as Hang from "@moq/hang";
+
+// Catalog — describes tracks and their codec properties
+import * as Catalog from "@moq/hang/catalog";
+
+// Container — media framing (CMAF and Legacy formats)
+import * as Container from "@moq/hang/container";
+
+// CMAF (fMP4) and Legacy (varint-timestamp + raw bitstream) are both available:
+// Container.Cmaf — createVideoInitSegment, createAudioInitSegment, encodeDataSegment, decodeDataSegment, etc.
+// Container.Legacy — Producer / Consumer classes
+```
+
+For watching and publishing, use the dedicated packages:
+
+```typescript
+import * as Watch from "@moq/watch";
+import * as Publish from "@moq/publish";
+```
+
+## Related Packages
+
+- **[@moq/watch](/lib/js/@moq/watch)** — Subscribe to and render MoQ broadcasts
+- **[@moq/publish](/lib/js/@moq/publish)** — Publish media to MoQ broadcasts
+- **[@moq/net](/lib/js/@moq/net)** — Core pub/sub transport protocol
+- **[@moq/signals](/lib/js/@moq/signals)** — Reactive signals library
+
+## Protocol Specification
+
+See the [hang specification](https://datatracker.ietf.org/doc/draft-lcurley-moq-hang/).
+
+## Next Steps
+
+- Learn about [watching streams](/lib/js/@moq/hang/watch)
+- Learn about [publishing streams](/lib/js/@moq/hang/publish)
+- Use [Web Components](/lib/js/env/web)
+- Use [@moq/net](/lib/js/@moq/net) for custom protocols
+- View [code examples](https://github.com/moq-dev/moq/tree/main/js)
diff --git a/doc/lib/js/@moq/hang/publish.md b/doc/lib/js/@moq/hang/publish.md
new file mode 100644
index 0000000000..9ab072cbc9
--- /dev/null
+++ b/doc/lib/js/@moq/hang/publish.md
@@ -0,0 +1,123 @@
+---
+title: Publishing Streams
+description: Publish camera, microphone, or screen to MoQ
+---
+
+# Publishing Streams
+
+This guide covers how to publish media to MoQ relays using `@moq/publish`.
+
+## Web Component
+
+The simplest way to publish:
+
+```html
+
+
+
+
+
+```
+
+### Attributes
+
+| Attribute | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `url` | string | required | Relay server URL |
+| `name` | string | required | Broadcast name |
+| `device` | string | "camera" | "camera" or "screen" |
+| `audio` | boolean | false | Enable audio |
+| `video` | boolean | false | Enable video |
+| `controls` | boolean | false | Show controls |
+
+## JavaScript API
+
+For more control, use `@moq/publish` directly:
+
+```typescript
+import * as Moq from "@moq/net";
+import * as Publish from "@moq/publish";
+
+const connection = await Moq.Connection.connect(
+ new URL("https://relay.example.com/anon")
+);
+
+const publish = new Publish.Broadcast({
+ connection,
+ enabled: true,
+ name: "alice.hang",
+ video: {
+ enabled: true,
+ device: "camera",
+ },
+ audio: {
+ enabled: true,
+ },
+});
+```
+
+### Switching Devices
+
+```typescript
+// Switch from camera to screen
+publish.video.device.set("screen");
+
+// Switch back to camera
+publish.video.device.set("camera");
+```
+
+### Enable/Disable Tracks
+
+```typescript
+// Disable video (audio only)
+publish.video.enabled.set(false);
+
+// Re-enable video
+publish.video.enabled.set(true);
+
+// Mute audio
+publish.audio.enabled.set(false);
+```
+
+## UI Overlay
+
+Use `@moq/publish/ui` for the Web Component UI overlay. The `` element wraps a nested ``:
+
+```html
+
+
+
+
+
+
+
+```
+
+## Authentication
+
+Include JWT token in the URL:
+
+```html
+
+
+```
+
+See [Authentication](/bin/relay/auth) for token generation.
+
+## Next Steps
+
+- Learn about [watching streams](/lib/js/@moq/hang/watch)
+- View [code examples](https://github.com/moq-dev/moq/tree/main/js)
+- Learn about [Web Components](/lib/js/env/web)
diff --git a/doc/lib/js/@moq/hang/watch.md b/doc/lib/js/@moq/hang/watch.md
new file mode 100644
index 0000000000..c33c6602e1
--- /dev/null
+++ b/doc/lib/js/@moq/hang/watch.md
@@ -0,0 +1,119 @@
+---
+title: Watching Streams
+description: Subscribe to and render MoQ broadcasts
+---
+
+# Watching Streams
+
+This guide covers how to subscribe to and render MoQ broadcasts using `@moq/watch`.
+
+## Web Component
+
+The simplest way to watch a stream:
+
+```html
+
+
+
+
+
+```
+
+### Attributes
+
+| Attribute | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `url` | string | required | Relay server URL |
+| `name` | string | required | Broadcast name |
+| `controls` | boolean | false | Show playback controls |
+| `paused` | boolean | false | Pause playback |
+| `muted` | boolean | false | Mute audio |
+| `volume` | number | 1 | Audio volume (0-1) |
+
+## JavaScript API
+
+For more control, use `@moq/watch` directly:
+
+```typescript
+import * as Moq from "@moq/net";
+import * as Watch from "@moq/watch";
+
+const connection = await Moq.Connection.connect(
+ new URL("https://relay.example.com/anon")
+);
+
+const watch = new Watch.Broadcast({
+ connection,
+ enabled: true,
+ name: "alice.hang",
+ reload: true,
+});
+```
+
+## Playback Controls
+
+### Pause/Resume
+
+```typescript
+// Using attribute
+watch.setAttribute("paused", "");
+watch.removeAttribute("paused");
+```
+
+### Volume Control (Web Component)
+
+```typescript
+const el = document.querySelector("moq-watch");
+
+// Set volume (0-1)
+el.setAttribute("volume", "0.5");
+
+// Mute/unmute
+el.setAttribute("muted", "");
+el.removeAttribute("muted");
+```
+
+## UI Overlay
+
+Use `@moq/watch/ui` for the Web Component UI overlay. The `` element wraps a nested ``:
+
+```html
+
+
+
+
+
+
+
+```
+
+Or use Web Components directly:
+
+```tsx
+import "@moq/watch/element";
+
+function VideoPlayer(props) {
+ return (
+
+
+
+ );
+}
+```
+
+## Next Steps
+
+- Learn about [publishing streams](/lib/js/@moq/hang/publish)
+- View [code examples](https://github.com/moq-dev/moq/tree/main/js)
+- Learn about [Web Components](/lib/js/env/web)
diff --git a/doc/lib/js/@moq/net.md b/doc/lib/js/@moq/net.md
new file mode 100644
index 0000000000..a5e7b9c8bc
--- /dev/null
+++ b/doc/lib/js/@moq/net.md
@@ -0,0 +1,101 @@
+---
+title: "@moq/net"
+description: Real-time pub/sub with caching, fan-out, and prioritization
+---
+
+# @moq/net
+
+[](https://www.npmjs.com/package/@moq/net)
+[](https://www.typescriptlang.org/)
+
+The networking layer for [Media over QUIC](https://moq.dev/) in TypeScript: real-time pub/sub with built-in caching, fan-out, and prioritization, on top of QUIC. At session setup it negotiates one of two wire protocols: the simplified [moq-lite](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/) protocol or the full IETF [moq-transport](https://datatracker.ietf.org/group/moq/documents/) protocol.
+
+## Overview
+
+`@moq/net` is the browser equivalent of the Rust `moq-net` crate, providing the core networking layer for MoQ. For higher-level media functionality, use [@moq/hang](/lib/js/@moq/hang/).
+
+## Installation
+
+```bash
+bun add @moq/net
+# or
+npm add @moq/net
+pnpm add @moq/net
+yarn add @moq/net
+```
+
+## Quick Start
+
+### Basic Connection
+
+See [`js/net/examples/connection.ts`](https://github.com/moq-dev/moq/blob/main/js/net/examples/connection.ts)
+
+### Publishing Data
+
+See [`js/net/examples/publish.ts`](https://github.com/moq-dev/moq/blob/main/js/net/examples/publish.ts)
+
+### Subscribing to Data
+
+See [`js/net/examples/subscribe.ts`](https://github.com/moq-dev/moq/blob/main/js/net/examples/subscribe.ts)
+
+### Stream Discovery
+
+See [`js/net/examples/discovery.ts`](https://github.com/moq-dev/moq/blob/main/js/net/examples/discovery.ts)
+
+## Core Concepts
+
+### Broadcasts
+
+A collection of related tracks.
+
+### Tracks
+
+Named streams within a broadcast, published by the producer and consumed via `subscribe`.
+
+### Groups
+
+Collections of frames (usually aligned with keyframes).
+
+### Frames
+
+Individual data chunks.
+
+See the [publishing example](https://github.com/moq-dev/moq/blob/main/js/net/examples/publish.ts) for usage of all core concepts.
+
+## Advanced Usage
+
+### Authentication
+
+Pass JWT tokens via query parameters in the URL. See [Authentication guide](/bin/relay/auth) for details and [`js/token/examples/sign-and-verify.ts`](https://github.com/moq-dev/moq/blob/main/js/token/examples/sign-and-verify.ts) for a working example.
+
+## Running Server-Side
+
+`@moq/net` can also run server-side using a [WebTransport polyfill](https://github.com/fails-components/webtransport). See the [`js/net/README.md`](https://github.com/moq-dev/moq/blob/main/js/net/README.md#server-side-usage) for setup instructions.
+
+## Browser Compatibility
+
+Requires **WebTransport** support:
+
+- Chrome 97+
+- Edge 97+
+- Brave (recent versions)
+
+Firefox and Safari support is experimental or planned.
+
+## Examples
+
+For more examples, see:
+
+- [TypeScript examples](https://github.com/moq-dev/moq/tree/main/js)
+- [demo](https://github.com/moq-dev/moq/tree/main/demo/web)
+
+## Protocol Specification
+
+See the [moq-lite specification](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/) for protocol details.
+
+## Next Steps
+
+- Build media apps with [@moq/hang](/lib/js/@moq/hang/)
+- Learn about [Web Components](/lib/js/env/web)
+- View [code examples](https://github.com/moq-dev/moq/tree/main/js)
+- Read the [Concepts guide](/concept/)
diff --git a/doc/lib/js/@moq/publish.md b/doc/lib/js/@moq/publish.md
new file mode 100644
index 0000000000..caf8b21d52
--- /dev/null
+++ b/doc/lib/js/@moq/publish.md
@@ -0,0 +1,117 @@
+---
+title: "@moq/publish"
+description: Publish media to MoQ broadcasts
+---
+
+# @moq/publish
+
+[](https://www.npmjs.com/package/@moq/publish)
+[](https://www.typescriptlang.org/)
+
+Publish media to MoQ broadcasts. Provides both a JavaScript API and a `` Web Component, plus an optional `` overlay.
+
+## Installation
+
+```bash
+bun add @moq/publish
+# or
+npm add @moq/publish
+```
+
+### No-build CDN usage
+
+For quick demos or single-page embeds where a bundler is overkill, load the
+package straight from jsDelivr with the `+esm` endpoint. jsDelivr transforms
+the published file and rewrites bare imports (like `@moq/hang`, `@moq/net`)
+to other `+esm` URLs, so it loads in the browser with no import map or local
+build step:
+
+```html
+
+
+
+
+
+
+
+```
+
+Pin a version range in the URL for production — e.g.
+`https://cdn.jsdelivr.net/npm/@moq/publish@0.2/element.js/+esm`. [esm.sh](https://esm.sh)
+(`https://esm.sh/@moq/publish/element`) works the same way if you prefer it.
+
+For anything beyond embedding on a static page, install the package and use
+a real bundler (the examples below).
+
+## Web Component
+
+```html
+
+
+
+
+
+```
+
+**Attributes:**
+
+- `url` (required) — Relay server URL
+- `name` (required) — Broadcast name
+- `device` — "camera" or "screen" (default: "camera")
+- `audio` — Enable audio capture (boolean)
+- `video` — Enable video capture (boolean)
+- `controls` — Show publishing controls (boolean)
+
+## UI Overlay
+
+Import `@moq/publish/ui` for a Web Component overlay with device selection and publishing controls:
+
+```html
+
+
+
+
+
+
+
+```
+
+The `` element automatically discovers the nested `` and wires up reactive controls.
+
+## JavaScript API
+
+```typescript
+import * as Publish from "@moq/publish";
+
+const broadcast = new Publish.Broadcast({
+ connection,
+ enabled: true,
+ name: "alice.hang",
+ video: { enabled: true, device: "camera" },
+ audio: { enabled: true },
+});
+
+// Reactive controls
+broadcast.video.device.set("screen");
+broadcast.name.set("bob.hang");
+```
+
+## Related Packages
+
+- **[@moq/watch](/lib/js/@moq/watch)** — Subscribe to and render MoQ broadcasts
+- **[@moq/hang](/lib/js/@moq/hang/)** — Core media library (catalog, container, support)
+- **[@moq/net](/lib/js/@moq/net)** — Core pub/sub transport protocol
diff --git a/doc/lib/js/@moq/signals.md b/doc/lib/js/@moq/signals.md
new file mode 100644
index 0000000000..c2f83f6639
--- /dev/null
+++ b/doc/lib/js/@moq/signals.md
@@ -0,0 +1,219 @@
+---
+title: "@moq/signals"
+description: Reactive signals library
+---
+
+# @moq/signals
+
+Reactive signals library used by `@moq/hang` for state management.
+
+## Overview
+
+`@moq/signals` provides:
+
+- Reactive primitives for state management
+- Framework adapters (React, Solid, Vue)
+- Used internally by `@moq/hang`
+
+## Installation
+
+```bash
+bun add @moq/signals
+# or
+npm add @moq/signals
+```
+
+## Basic Usage
+
+### Creating Signals
+
+```typescript
+import { signal } from "@moq/signals";
+
+const count = signal(0);
+
+// Get current value
+console.log(count.get()); // 0
+
+// Set value
+count.set(1);
+
+// Subscribe to changes
+const unsubscribe = count.subscribe((value) => {
+ console.log("Count changed:", value);
+});
+
+// Cleanup
+unsubscribe();
+```
+
+### Computed Signals
+
+```typescript
+import { signal, computed } from "@moq/signals";
+
+const firstName = signal("John");
+const lastName = signal("Doe");
+
+const fullName = computed(() => {
+ return `${firstName.get()} ${lastName.get()}`;
+});
+
+console.log(fullName.get()); // "John Doe"
+
+firstName.set("Jane");
+console.log(fullName.get()); // "Jane Doe"
+```
+
+### Effects
+
+```typescript
+import { signal, effect } from "@moq/signals";
+
+const count = signal(0);
+
+const cleanup = effect(() => {
+ console.log("Count is:", count.get());
+});
+
+count.set(1); // Logs: "Count is: 1"
+count.set(2); // Logs: "Count is: 2"
+
+cleanup();
+```
+
+## Framework Adapters
+
+### React
+
+```typescript
+import { signal } from "@moq/signals";
+import { react } from "@moq/signals/react";
+import { useEffect, useState } from "react";
+
+const count = signal(0);
+
+function Counter() {
+ const reactiveCount = react(count);
+
+ return (
+
+ Count: {reactiveCount()}
+ count.set(count.get() + 1)}>
+ Increment
+
+
+ );
+}
+```
+
+### SolidJS
+
+```typescript
+import { signal } from "@moq/signals";
+import { solid } from "@moq/signals/solid";
+
+const count = signal(0);
+
+function Counter() {
+ const solidCount = solid(count);
+
+ return (
+
+ Count: {solidCount()}
+ count.set(count.get() + 1)}>
+ Increment
+
+
+ );
+}
+```
+
+### Vue
+
+```typescript
+import { signal } from "@moq/signals";
+import { vue } from "@moq/signals/vue";
+
+const count = signal(0);
+
+// In Vue component
+const vueCount = vue(count);
+```
+
+## Usage with @moq/hang
+
+All `@moq/hang` properties are signals:
+
+```typescript
+import "@moq/watch/element";
+import { react } from "@moq/signals/react";
+
+const watch = document.querySelector("moq-watch") as MoqWatch;
+
+// Convert to React-compatible signal
+const volume = react(watch.volume);
+const paused = react(watch.paused);
+
+function Controls() {
+ return (
+
+ Volume: {volume()}
+ Paused: {paused() ? "Yes" : "No"}
+
+ watch.volume.set(Number(e.target.value))}
+ />
+
+ );
+}
+```
+
+## API Reference
+
+### signal(initialValue)
+
+Create a new reactive signal.
+
+```typescript
+const s = signal(initialValue: T): Signal
+```
+
+### computed(fn)
+
+Create a computed signal that derives from other signals.
+
+```typescript
+const c = computed(fn: () => T): ReadonlySignal
+```
+
+### effect(fn)
+
+Run a side effect when signals change.
+
+```typescript
+const cleanup = effect(fn: () => void): () => void
+```
+
+### batch(fn)
+
+Batch multiple signal updates.
+
+```typescript
+batch(() => {
+ signal1.set(1);
+ signal2.set(2);
+ // Subscribers notified once at the end
+});
+```
+
+## Next Steps
+
+- Use with [@moq/hang](/lib/js/@moq/hang/)
+- View [code examples](https://github.com/moq-dev/moq/tree/main/js)
+- Learn about [Web Components](/lib/js/env/web)
diff --git a/doc/lib/js/@moq/token.md b/doc/lib/js/@moq/token.md
new file mode 100644
index 0000000000..13267d66b1
--- /dev/null
+++ b/doc/lib/js/@moq/token.md
@@ -0,0 +1,64 @@
+---
+title: "@moq/token"
+description: JWT token library for browsers
+---
+
+# @moq/token
+
+JWT token generation and verification for MoQ in browsers.
+
+## Overview
+
+`@moq/token` provides:
+
+- Generate signing keys (HMAC, RSA, ECDSA, EdDSA)
+- Sign and verify JWT tokens
+- Compatible with moq-relay authentication and `moq-token-cli`
+
+## Installation
+
+```bash
+bun add @moq/token
+```
+
+## Usage
+
+For a complete working example covering key loading, signing, and verification, see [`js/token/examples/sign-and-verify.ts`](https://github.com/moq-dev/moq/blob/main/js/token/examples/sign-and-verify.ts).
+
+## Token Claims
+
+| Claim | Type | Description |
+|-------|------|-------------|
+| `root` | string | Root path for operations |
+| `put` | `string \| string[]?` | Publishing permission paths |
+| `get` | `string \| string[]?` | Subscription permission paths |
+| `cluster` | boolean? | Cluster node flag |
+| `exp` | number? | Expiration timestamp |
+| `iat` | number? | Issued at timestamp |
+
+## CLI Usage
+
+The package includes a CLI tool:
+
+```bash
+# Generate a key
+bun run @moq/token generate --key root.jwk
+
+# Sign a token
+bun run @moq/token sign --key root.jwk --root "rooms/123" --publish alice
+
+# Verify a token from stdin
+bun run @moq/token verify --key root.jwk --root "rooms/123" < token.jwt
+```
+
+## Security Considerations
+
+- **Never expose secret keys** in browser code
+- Use asymmetric keys when possible
+- Generate tokens server-side for production
+- Set appropriate expiration times
+
+## Next Steps
+
+- Set up [Relay Authentication](/bin/relay/auth)
+- Use [@moq/net](/lib/js/@moq/net) for connections
diff --git a/doc/lib/js/@moq/watch.md b/doc/lib/js/@moq/watch.md
new file mode 100644
index 0000000000..6169e34435
--- /dev/null
+++ b/doc/lib/js/@moq/watch.md
@@ -0,0 +1,179 @@
+---
+title: "@moq/watch"
+description: Subscribe to and render MoQ broadcasts
+---
+
+# @moq/watch
+
+[](https://www.npmjs.com/package/@moq/watch)
+[](https://www.typescriptlang.org/)
+
+Subscribe to and render MoQ broadcasts. Provides both a JavaScript API and a `` Web Component, plus an optional `` overlay.
+
+## Installation
+
+```bash
+bun add @moq/watch
+# or
+npm add @moq/watch
+```
+
+### No-build CDN usage
+
+For quick demos or single-page embeds where a bundler is overkill, load the
+package straight from jsDelivr with the `+esm` endpoint. jsDelivr transforms
+the published file and rewrites bare imports (like `@moq/hang`, `@moq/net`)
+to other `+esm` URLs, so it loads in the browser with no import map or local
+build step:
+
+```html
+
+
+
+
+
+
+
+```
+
+Pin a version range in the URL for production, e.g.
+`https://cdn.jsdelivr.net/npm/@moq/watch@0.2/element.js/+esm`. [esm.sh](https://esm.sh)
+(`https://esm.sh/@moq/watch/element`) works the same way if you prefer it.
+
+For anything beyond embedding on a static page, install the package and use
+a real bundler (the examples below).
+
+## Web Component
+
+```html
+
+
+
+
+
+```
+
+**Attributes:**
+
+- `url` (required): Relay server URL
+- `name` (required): Broadcast name
+- `controls`: Show playback controls (boolean)
+- `paused`: Pause playback (boolean)
+- `muted`: Mute audio (boolean)
+- `volume`: Audio volume (0 to 1, default: 1)
+- `catalog-format`: Catalog format. One of `"hang"`, `"msf"` (see [MSF](/concept/standard/msf)), or `"manual"` (supply the catalog yourself). When omitted, the format is auto-detected from the broadcast `name` extension (`.hang` or `.msf`), falling back to `"hang"`.
+
+## Catalog Formats
+
+`@moq/watch` can consume either the default [hang](/concept/layer/hang) catalog
+or [MSF](/concept/standard/msf) (MoQ Streaming Format). The format is detected
+from the broadcast name extension by default. `room/alice.hang` uses hang,
+`room/alice.msf` uses MSF. Set `catalog-format` explicitly to override:
+
+```html
+
+
+
+```
+
+```typescript
+import * as Watch from "@moq/watch";
+
+const broadcast = new Watch.Broadcast({
+ connection,
+ enabled: true,
+ name: "alice.hang",
+ catalogFormat: "msf",
+});
+
+// or toggle at runtime
+broadcast.catalogFormat.set("msf");
+```
+
+### Manual catalogs
+
+Use `catalog-format="manual"` (or `catalogFormat: "manual"`) to skip the catalog
+track entirely and supply a `Catalog.Root` directly. The connection and
+broadcast name are still required, since they're used to subscribe to the media
+tracks named by the catalog. Update the catalog at any time by writing to
+the signal:
+
+```typescript
+import * as Watch from "@moq/watch";
+
+const broadcast = new Watch.Broadcast({
+ connection,
+ enabled: true,
+ name: "alice.hang",
+ catalogFormat: "manual",
+ catalog: {
+ video: { renditions: { hd: { codec: "vp09.00.10.08", container: { kind: "legacy" } } } },
+ },
+});
+
+// Replace at runtime
+broadcast.catalog.set(nextCatalog);
+```
+
+The web component exposes the same field as a JS property:
+
+```typescript
+const el = document.querySelector("moq-watch")!;
+el.catalogFormat = "manual";
+el.catalog = myCatalog;
+```
+
+> Switching `catalogFormat` between `"manual"` and a fetched format (`"hang"` /
+> `"msf"`) tears down the previous fetch loop, which clears `catalog`. Set the
+> catalog *after* switching to `"manual"`, not before.
+
+## UI Overlay
+
+Import `@moq/watch/ui` for a Web Component overlay with buffering indicator, stats panel, and playback controls:
+
+```html
+
+
+
+
+
+
+
+```
+
+The `` element automatically discovers the nested `` and wires up reactive controls.
+
+## JavaScript API
+
+```typescript
+import * as Watch from "@moq/watch";
+
+const broadcast = new Watch.Broadcast({
+ connection,
+ enabled: true,
+ name: "alice.hang",
+ reload: true,
+});
+```
+
+## Related Packages
+
+- **[@moq/publish](/lib/js/@moq/publish)**: Publish media to MoQ broadcasts
+- **[@moq/hang](/lib/js/@moq/hang/)**: Core media library (catalog, container, support)
+- **[@moq/net](/lib/js/@moq/net)**: Core pub/sub transport protocol
diff --git a/doc/lib/js/env/native.md b/doc/lib/js/env/native.md
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/doc/lib/js/env/web.md b/doc/lib/js/env/web.md
new file mode 100644
index 0000000000..c4081571a9
--- /dev/null
+++ b/doc/lib/js/env/web.md
@@ -0,0 +1,355 @@
+---
+title: Web Components
+description: Web Components API reference
+---
+
+# Web Components
+
+`@moq/hang` provides Web Components for easy integration into any web page or framework.
+
+## Why Web Components?
+
+- **Framework agnostic** - Works with React, Vue, Solid, or vanilla JS
+- **Easy integration** - Just import and use like HTML
+- **Encapsulated** - Shadow DOM for style isolation
+- **Reactive** - Automatically update when attributes change
+
+## Loading From a CDN (No Bundler)
+
+For quick demos or embeds on a static page, both `@moq/watch` and
+`@moq/publish` can be loaded straight from jsDelivr with no build step.
+Appending `/+esm` to the URL tells jsDelivr to transform the file and
+rewrite bare imports (like `@moq/hang`, `@moq/net`) to other `+esm`
+URLs, so it loads in the browser without an import map:
+
+```html
+
+
+
+
+
+```
+
+Pin a version range in the URL for production — e.g.
+`https://cdn.jsdelivr.net/npm/@moq/watch@0.2/element.js/+esm`. [esm.sh](https://esm.sh)
+(`https://esm.sh/@moq/watch/element`) works the same way if you prefer it.
+
+This is the fastest way to try MoQ in a blog post or demo page, but for
+real apps you should [install the packages](#available-components) and
+use a bundler — you'll get tree-shaking, offline dev, and no dependency
+on a third-party CDN's availability.
+
+## Available Components
+
+### ``
+
+Publish camera/microphone or screen as a MoQ broadcast.
+
+**Attributes:**
+
+- `url` (required) - Relay server URL
+- `name` (required) - Broadcast name
+- `device` - "camera" or "screen" (default: "camera")
+- `audio` - Enable audio capture (boolean)
+- `video` - Enable video capture (boolean)
+- `controls` - Show publishing controls (boolean)
+
+**Example:**
+
+```html
+
+
+
+
+
+
+```
+
+### ``
+
+Subscribe to and render a MoQ broadcast.
+
+**Attributes:**
+
+- `url` (required) - Relay server URL
+- `name` (required) - Broadcast name
+- `controls` - Show playback controls (boolean)
+- `paused` - Pause playback (boolean)
+- `muted` - Mute audio (boolean)
+- `volume` - Audio volume (0-1, default: 1)
+
+**Example:**
+
+```html
+
+
+
+
+
+
+```
+
+### ``
+
+Display browser support information for watching streams.
+
+**Attributes:**
+
+- `show` - "always", "warning", "error", or "never" (default: "warning")
+- `details` - show detailed codec information
+
+**Example:**
+
+```html
+
+
+
+
+```
+
+### ``
+
+Display browser support information for publishing streams.
+
+**Attributes:**
+
+- `show` - "always", "warning", "error", or "never" (default: "warning")
+- `details` - show detailed codec information
+
+**Example:**
+
+```html
+
+
+
+
+```
+
+## Using JavaScript Properties
+
+HTML attributes are strings, but JavaScript properties are typed and reactive:
+
+```typescript
+// Get element reference
+const watch = document.querySelector("moq-watch") as MoqWatch;
+
+// Set properties (reactive)
+watch.volume.set(0.8);
+watch.muted.set(false);
+watch.paused.set(true);
+
+// Subscribe to changes
+watch.volume.subscribe((vol) => {
+ console.log("Volume changed:", vol);
+});
+
+// Get current value
+const currentVolume = watch.volume.get();
+```
+
+## Reactive Properties
+
+All properties are signals from `@moq/signals`:
+
+```typescript
+import MoqWatch from "@moq/watch/element";
+
+const watch = document.querySelector("moq-watch") as MoqWatch;
+
+// These are all reactive signals:
+watch.volume // Signal
+watch.muted // Signal
+watch.paused // Signal
+watch.url // Signal
+watch.name // Signal
+```
+
+## Framework Integration
+
+### React
+
+```tsx
+import { useEffect, useRef } from "react";
+import "@moq/watch/element";
+
+function VideoPlayer({ url, name }) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (ref.current) {
+ ref.current.volume.set(0.8);
+ }
+ }, []);
+
+ return (
+
+
+
+ );
+}
+```
+
+### SolidJS
+
+Use `@moq/watch/ui` and `@moq/publish/ui` for ready-made UI overlays, or use Web Components directly:
+
+```tsx
+import "@moq/watch/element";
+
+function VideoPlayer(props) {
+ return (
+
+
+
+ );
+}
+```
+
+### Vue
+
+```vue
+
+
+
+
+
+
+
+```
+
+## Styling
+
+Web Components use Shadow DOM, so global styles won't apply. Use CSS custom properties (variables) or style child elements:
+
+```html
+
+
+
+
+
+```
+
+## Tree-Shaking
+
+To prevent tree-shaking from removing component registrations, explicitly import with `/element` suffix:
+
+```typescript
+// Correct
+import "@moq/watch/element";
+
+// May be tree-shaken (don't use)
+import "@moq/watch";
+```
+
+## TypeScript Support
+
+Full TypeScript support with type definitions:
+
+```typescript
+import MoqWatch from "@moq/watch/element";
+import MoqPublish from "@moq/publish/element";
+
+const watch: MoqWatch = document.querySelector("moq-watch")!;
+const publish: MoqPublish = document.querySelector("moq-publish")!;
+```
+
+## Events
+
+Components emit custom events:
+
+```typescript
+const watch = document.querySelector("moq-watch") as MoqWatch;
+
+watch.addEventListener("play", () => {
+ console.log("Playback started");
+});
+
+watch.addEventListener("pause", () => {
+ console.log("Playback paused");
+});
+
+watch.addEventListener("error", (e) => {
+ console.error("Error:", e.detail);
+});
+```
+
+## Browser Compatibility
+
+Requires modern browser features:
+
+- **WebTransport** - Chromium-based browsers (Chrome, Edge, Brave)
+- **WebCodecs** - For media encoding/decoding
+- **WebAudio** - For audio playback
+
+**Supported browsers:**
+
+- Chrome 97+
+- Edge 97+
+- Brave (recent versions)
+
+**Experimental support:**
+
+- Firefox (behind flag)
+- Safari (future support planned)
+
+## Production Deployment
+
+For production, you'll want to:
+
+1. Use a production relay ([moq-relay](/bin/relay/))
+2. Set up proper [authentication](/bin/relay/auth)
+3. Use a bundler, see [examples](https://github.com/moq-dev/web) for Vite, Webpack, esbuild, and more.
+
+**NOTE** both of these libraries are intended for client-side.
+However, `@moq/net` can run on the server side using [Deno](https://deno.com/) or a [WebTransport polyfill](https://github.com/moq-dev/web-transport/tree/main/rs/web-transport-ws).
+Don't even try to run `@moq/hang` on the server side or you'll run into a ton of issues, *especially* with Next.js.
+
+## Next Steps
+
+- Learn about [@moq/hang](/lib/js/@moq/hang/)
+- Use [@moq/net](/lib/js/@moq/net) for custom protocols
+- View [code examples](https://github.com/moq-dev/moq/tree/main/js)
diff --git a/doc/lib/js/index.md b/doc/lib/js/index.md
new file mode 100644
index 0000000000..5fa7cc6dfc
--- /dev/null
+++ b/doc/lib/js/index.md
@@ -0,0 +1,175 @@
+---
+title: TypeScript Libraries
+description: TypeScript/JavaScript implementation for browsers
+---
+
+# TypeScript Libraries
+
+The TypeScript implementation brings MoQ to web browsers using modern APIs like WebTransport and WebCodecs.
+
+## Core Libraries
+
+### @moq/net
+
+[](https://www.npmjs.com/package/@moq/net)
+
+Core pub/sub transport protocol for browsers. Implements the [moq-lite specification](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/).
+
+**Features:**
+
+- WebTransport-based QUIC
+- Broadcasts, tracks, groups, frames
+- Browser and server-side support (with polyfill)
+
+[Learn more](/lib/js/@moq/net)
+
+### @moq/hang
+
+[](https://www.npmjs.com/package/@moq/hang)
+
+High-level media library with Web Components for streaming audio and video.
+
+**Features:**
+
+- Web Components (easiest integration)
+- JavaScript API for advanced use
+- WebCodecs-based encoding/decoding
+- Reactive state management
+
+[Learn more](/lib/js/@moq/hang/)
+
+## Media Packages
+
+### @moq/watch
+
+[](https://www.npmjs.com/package/@moq/watch)
+
+Subscribe to and render MoQ broadcasts. Includes both a JavaScript API and a `` Web Component, plus an optional `` overlay.
+
+[Learn more](/lib/js/@moq/watch)
+
+### @moq/publish
+
+[](https://www.npmjs.com/package/@moq/publish)
+
+Publish media to MoQ broadcasts. Includes both a JavaScript API and a `` Web Component, plus an optional `` overlay.
+
+[Learn more](/lib/js/@moq/publish)
+
+## Utilities
+
+### @moq/signals
+
+Reactive signals library used by hang for state management.
+
+[Learn more](/lib/js/@moq/signals)
+
+### @moq/clock
+
+Clock utilities for timestamp synchronization.
+
+### @moq/token
+
+JWT token generation and verification for browsers.
+
+[Learn more](/lib/js/@moq/token)
+
+## Installation
+
+```bash
+bun add @moq/net
+bun add @moq/watch
+bun add @moq/publish
+
+# or with other package managers
+npm add @moq/net
+npm add @moq/watch
+npm add @moq/publish
+```
+
+## Quick Start
+
+### Using Web Components
+
+The easiest way to add MoQ to your web page:
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+[Learn more about Web Components](/lib/js/env/web)
+
+### Using JavaScript API
+
+For more control, use the JavaScript API directly. See the [`js/net/examples/`](https://github.com/moq-dev/moq/tree/main/js/net/examples) directory for working examples of [connecting](https://github.com/moq-dev/moq/blob/main/js/net/examples/connection.ts), [publishing](https://github.com/moq-dev/moq/blob/main/js/net/examples/publish.ts), [subscribing](https://github.com/moq-dev/moq/blob/main/js/net/examples/subscribe.ts), and [discovery](https://github.com/moq-dev/moq/blob/main/js/net/examples/discovery.ts).
+
+[Learn more about @moq/net](/lib/js/@moq/net)
+
+## Browser Compatibility
+
+Requires modern browser features:
+
+- **WebTransport** - Chromium-based browsers (Chrome, Edge, Brave)
+- **WebCodecs** - For media encoding/decoding
+- **WebAudio** - For audio playback
+
+**Supported browsers:**
+
+- Chrome 97+
+- Edge 97+
+- Brave (recent versions)
+
+**Experimental support:**
+
+- Firefox (behind flag)
+- Safari (future support planned)
+
+## Framework Integration
+
+The reactive API works with popular frameworks:
+
+- **React** — See [`js/signals/src/react.ts`](https://github.com/moq-dev/moq/blob/main/js/signals/src/react.ts) for `useValue` and `useSignal` hooks
+- **SolidJS** — See [`js/signals/src/solid.ts`](https://github.com/moq-dev/moq/blob/main/js/signals/src/solid.ts) for `createAccessor` and `createPair` helpers
+
+Use `@moq/watch/ui` and `@moq/publish/ui` for ready-made Web Component overlays.
+
+## Demo Application
+
+Check out the [demo](https://github.com/moq-dev/moq/tree/main/demo/web) for complete examples:
+
+- Video conferencing
+- Screen sharing
+- Text chat
+- Quality selection
+
+## Next Steps
+
+- Explore [@moq/net](/lib/js/@moq/net) - Core protocol
+- Explore [@moq/hang](/lib/js/@moq/hang/) - Media library
+- Learn about [Web Components](/lib/js/env/web)
+- View [code examples](https://github.com/moq-dev/moq/tree/main/js)
diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md
new file mode 100644
index 0000000000..8109f9922e
--- /dev/null
+++ b/doc/lib/kt/index.md
@@ -0,0 +1,74 @@
+---
+title: Kotlin Libraries
+description: Kotlin Multiplatform library for Media over QUIC on JVM and Android
+---
+
+# Kotlin Libraries
+
+The Kotlin bindings expose [Media over QUIC](/) to Android apps and JVM-based services. Built on the same Rust core ([moq-ffi](https://crates.io/crates/moq-ffi)) as the Python and Swift packages, wrapped with idiomatic `Flow` and coroutines.
+
+## Packages
+
+### dev.moq:moq
+
+A single Kotlin Multiplatform module that publishes both JVM and Android variants under one coordinate. Consumers add `dev.moq:moq:VERSION` and Gradle metadata resolution picks the right artifact for their target.
+
+**Features:**
+
+- Android (arm64-v8a, armeabi-v7a, x86_64) and desktop JVM (Linux, macOS, Windows)
+- `Flow`-based async sequences with structured cancellation
+- Native binaries bundled via JNI (Android) / JNA (desktop JVM)
+
+[Learn more](/lib/kt/moq)
+
+## Installation
+
+```kotlin
+// build.gradle.kts
+dependencies {
+ implementation("dev.moq:moq:0.2.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
+}
+```
+
+Published to Maven Central via [release-kt.yml](https://github.com/moq-dev/moq/blob/main/.github/workflows/release-kt.yml). Native binaries for all supported targets are bundled in the artifact, so no extra setup is required on the consumer side.
+
+## Quickstart
+
+```kotlin
+import dev.moq.*
+import kotlinx.coroutines.flow.collect
+import uniffi.moq.MoqClient
+import uniffi.moq.MoqOriginProducer
+
+// Wire an origin as both publish source and consume sink. Set just one
+// side for a subscribe-only or publish-only client.
+val origin = MoqOriginProducer()
+val client = MoqClient()
+client.setPublish(origin)
+client.setConsume(origin)
+
+val session = client.connect("https://relay.example.com")
+
+origin.use {
+ val consumer = origin.consume()
+ val announced = consumer.announced("demos/")
+ announced.announcements().collect { announcement ->
+ println("got broadcast ${announcement.path()}")
+
+ announcement.broadcast().subscribeCatalog().updates().collect { catalog ->
+ println("catalog: $catalog")
+ }
+ }
+}
+
+session.shutdown()
+```
+
+Cancelling the surrounding coroutine scope propagates through to the native consumer's `cancel()` via the wrapper's `onCompletion` hook.
+
+## Source and issues
+
+- Source: [kt/](https://github.com/moq-dev/moq/tree/main/kt) (in the monorepo)
+- README: [kt/README.md](https://github.com/moq-dev/moq/blob/main/kt/README.md)
+- Maven Central: [dev.moq:moq](https://central.sonatype.com/artifact/dev.moq/moq)
diff --git a/doc/lib/kt/moq.md b/doc/lib/kt/moq.md
new file mode 100644
index 0000000000..c571968805
--- /dev/null
+++ b/doc/lib/kt/moq.md
@@ -0,0 +1,129 @@
+---
+title: dev.moq:moq (Kotlin)
+description: Kotlin Multiplatform library for Media over QUIC
+---
+
+# dev.moq:moq
+
+The Kotlin Multiplatform module for [Media over QUIC](/).
+
+A single Maven coordinate that publishes JVM and Android variants. Gradle metadata picks the right one for your target, so there are no per-platform artifacts to track.
+
+## Install
+
+```kotlin
+// build.gradle.kts
+dependencies {
+ implementation("dev.moq:moq:0.2.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
+}
+```
+
+Native binaries are bundled for:
+
+- Android: arm64-v8a, armeabi-v7a, x86_64
+- JVM: Linux x86_64 + aarch64, macOS x86_64 + aarch64, Windows x86_64
+
+Android uses JNI (`jniLibs/`), desktop JVM uses JNA (resource-classpath layout). Both are bundled in the same AAR/JAR.
+
+## Connect
+
+```kotlin
+import uniffi.moq.MoqClient
+import uniffi.moq.MoqOriginProducer
+
+// Wire an origin as both publish source and consume sink for the
+// typical full-duplex client. Set just one side for a subscribe-only
+// or publish-only client.
+val origin = MoqOriginProducer()
+val client = MoqClient()
+client.setPublish(origin)
+client.setConsume(origin)
+
+val session = client.connect("https://relay.example.com")
+```
+
+For development against a relay with a self-signed certificate, configure the client before connecting:
+
+```kotlin
+val client = MoqClient()
+client.setTlsDisableVerify(true)
+client.setBind("127.0.0.1:0")
+client.setPublish(origin)
+client.setConsume(origin)
+val session = client.connect("https://localhost:4443")
+```
+
+When you're done, signal graceful shutdown to the peer:
+
+```kotlin
+session.shutdown() // alias for cancel(0u)
+```
+
+## Subscribe
+
+```kotlin
+import dev.moq.*
+import kotlinx.coroutines.flow.collect
+
+val consumer = origin.consume()
+val announced = consumer.announced("demos/")
+
+announced.announcements().collect { announcement ->
+ val catalog = announcement.broadcast().subscribeCatalog()
+ catalog.updates().collect { update ->
+ println("catalog: $update")
+ }
+}
+```
+
+## Publish
+
+```kotlin
+import dev.moq.*
+import uniffi.moq.MoqBroadcastProducer
+
+val broadcast = MoqBroadcastProducer()
+val audio = broadcast.publishMedia("opus", opusInitBytes)
+
+origin.publish("my-stream", broadcast)
+
+audio.writeFrame(payload, timestampUs = 0u)
+audio.writeFrame(payload, timestampUs = 20_000u)
+audio.finish()
+broadcast.finish()
+```
+
+## Cancellation
+
+The wrapper exposes consumers as Kotlin `Flow`s. Cancelling the collector's coroutine scope calls `cancel()` on the native side via the wrapper's `onCompletion` hook, releasing resources promptly:
+
+```kotlin
+val job = launch {
+ mediaConsumer.frames().collect { frame ->
+ process(frame)
+ }
+}
+
+// Later:
+job.cancel() // releases native resources
+```
+
+## Local development
+
+To build and run the JVM tests locally:
+
+```bash
+just check-ffi
+```
+
+This builds `moq-ffi` for the host arch, regenerates the UniFFI Kotlin bindings, drops the host cdylib into the JNA resource layout, and runs `gradle :moq:jvmTest`.
+
+Android targets are opt-in via `-Pandroid.enabled=true`. Local builds without the Android SDK still produce a working JVM variant.
+
+## See also
+
+- Source: [kt/](https://github.com/moq-dev/moq/tree/main/kt)
+- README: [kt/README.md](https://github.com/moq-dev/moq/blob/main/kt/README.md)
+- Maven Central: [dev.moq:moq](https://central.sonatype.com/artifact/dev.moq/moq)
+- The Rust crate this wraps: [moq-net](/lib/rs/crate/moq-net)
diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md
new file mode 100644
index 0000000000..40915481d8
--- /dev/null
+++ b/doc/lib/py/index.md
@@ -0,0 +1,87 @@
+---
+title: Python Libraries
+description: Python implementation for MoQ pub/sub
+---
+
+# Python Libraries
+
+The Python bindings expose [Media over QUIC](/) to scripts, services, and prototype tooling. Built on the same Rust core ([moq-ffi](https://crates.io/crates/moq-ffi)) as the Swift and Kotlin packages, wrapped with an idiomatic asyncio API.
+
+## Packages
+
+Two packages, split so the ergonomic API can evolve on its own cadence:
+
+### moq-rs
+
+[](https://pypi.org/project/moq-rs/)
+
+The package you want. Install `moq-rs` (the `moq` name is taken on PyPI), import `moq`. Real-time pub/sub with built-in caching, fan-out, and prioritization on top of QUIC, with a Pythonic API (no `Moq` prefixes, async context managers, async iterators). At session setup it negotiates either the `moq-lite` or `moq-transport` wire protocol.
+
+It is pure Python and depends on `moq-ffi` via a compatible-release pin, so it floats to the latest `moq-ffi` patch automatically. It is versioned independently of the Rust crates.
+
+### moq-ffi
+
+[](https://pypi.org/project/moq-ffi/)
+
+The raw UniFFI bindings (the `Moq`-prefixed classes), tracking the [`moq-ffi`](https://crates.io/crates/moq-ffi) Rust crate one-to-one. `moq-rs` pulls this in for you. Install it directly only if you need the unwrapped API or are building your own wrapper.
+
+## Installation
+
+```bash
+pip install moq-rs
+```
+
+This pulls in `moq-ffi`, for which prebuilt wheels are published for:
+
+- Linux x86_64 / aarch64 (manylinux_2_28)
+- macOS x86_64 / aarch64
+- Windows x86_64
+
+For other platforms (Alpine, BSD, etc.) `pip` falls back to building `moq-ffi` from source via the published sdist. You'll need a Rust toolchain and a C compiler.
+
+## Quickstart
+
+### Subscribe
+
+```python
+import asyncio
+import moq
+
+async def main():
+ async with moq.Client("https://relay.quic.video") as client:
+ async for announcement in client.announced():
+ catalog = await announcement.broadcast.catalog()
+
+ for name in catalog.audio:
+ async for frame in announcement.broadcast.subscribe_media(name):
+ print(f"frame: {len(frame.payload)} bytes, ts={frame.timestamp_us}")
+
+asyncio.run(main())
+```
+
+### Publish
+
+```python
+import asyncio
+import moq
+
+async def main():
+ async with moq.Client("https://relay.quic.video") as client:
+ broadcast = moq.BroadcastProducer()
+ audio = broadcast.publish_media("opus", opus_init_bytes)
+ client.publish("my-stream", broadcast)
+
+ audio.write_frame(payload, timestamp_us=0)
+ audio.write_frame(payload, timestamp_us=20_000)
+
+ audio.finish()
+ broadcast.finish()
+
+asyncio.run(main())
+```
+
+## Source and issues
+
+- Source: [py/moq-rs](https://github.com/moq-dev/moq/tree/main/py/moq-rs) (wrapper), [py/moq-ffi](https://github.com/moq-dev/moq/tree/main/py/moq-ffi) (raw bindings)
+- README: [py/moq-rs/README.md](https://github.com/moq-dev/moq/blob/main/py/moq-rs/README.md)
+- Example scripts: [py/moq-rs/examples](https://github.com/moq-dev/moq/tree/main/py/moq-rs/examples)
diff --git a/doc/lib/py/moq-rs.md b/doc/lib/py/moq-rs.md
new file mode 100644
index 0000000000..b8cafc2985
--- /dev/null
+++ b/doc/lib/py/moq-rs.md
@@ -0,0 +1,105 @@
+---
+title: moq-rs (Python)
+description: Python pub/sub for Media over QUIC
+---
+
+# moq-rs
+
+[](https://pypi.org/project/moq-rs/)
+
+Async pub/sub for [Media over QUIC](/) in Python.
+
+The underlying transport is the Rust [`moq-net`](/lib/rs/crate/moq-net) crate, exposed through UniFFI (the [`moq-ffi`](https://pypi.org/project/moq-ffi/) package) and wrapped in a Pythonic API: no `Moq` prefixes on user-facing types, async iterators for streams, async context managers for sessions. `moq-rs` is versioned independently of `moq-ffi` and floats to the latest compatible patch.
+
+## Install
+
+```bash
+pip install moq-rs
+```
+
+Requires Python 3.10+. The distribution is `moq-rs` (the `moq` name is taken on PyPI); the import name is `moq`. Installing it pulls in the `moq-ffi` native bindings automatically.
+
+## Concepts
+
+A **broadcast** is a collection of tracks identified by a path. A **track** is a live stream of frames. Producers write broadcasts to an origin; consumers subscribe to whatever has been announced.
+
+For unstructured byte streams (status, commands, sensor data), use `publish_track` / `subscribe_track`. For media with a known container format (audio/video), use `publish_media` / `subscribe_media` and the catalog will be populated automatically.
+
+## API summary
+
+### Connection
+
+```python
+async with moq.Client("https://relay.example.com") as client:
+ ...
+```
+
+`Client(url, *, tls_verify=True, publish=None, subscribe=None)`. Without `publish` / `subscribe` an internal origin is created automatically. Pass an `OriginProducer` to share state across multiple clients.
+
+### Publishing media
+
+```python
+broadcast = moq.BroadcastProducer()
+audio = broadcast.publish_media("opus", opus_init_bytes)
+client.publish("my-stream", broadcast)
+
+audio.write_frame(payload, timestamp_us=0)
+audio.finish()
+broadcast.finish()
+```
+
+Supported codec formats include `opus`, `avc3`, `hev1`, `av01`, `vp09`, and others — see [`hang`](/lib/rs/crate/hang) for the full list.
+
+### Subscribing to media
+
+```python
+async for announcement in client.announced("prefix/"):
+ catalog = await announcement.broadcast.catalog()
+ track_name = next(iter(catalog.audio))
+ consumer = announcement.broadcast.subscribe_media(track_name, catalog.audio[track_name].container, max_latency_ms=10_000)
+
+ async for frame in consumer:
+ ...
+```
+
+### Raw tracks (no codec)
+
+```python
+# Publish
+broadcast = moq.BroadcastProducer()
+track = broadcast.publish_track("events")
+track.write_frame(b'{"cmd": "ready"}')
+track.finish()
+
+# Subscribe
+async for group in broadcast_consumer.subscribe_track("events"):
+ async for frame in group:
+ print(frame)
+```
+
+`write_frame` on a track creates a one-frame group by default. Use `append_group()` for multi-frame groups (e.g., a video GOP).
+
+### Discovering broadcasts
+
+```python
+async for announcement in client.announced("live/"):
+ print(announcement.path)
+ ...
+
+# Or wait for a specific path:
+broadcast = await client.announced_broadcast("live/cam1")
+```
+
+## Examples
+
+The repo ships [example scripts](https://github.com/moq-dev/moq/tree/main/py/moq-rs/examples) you can run end-to-end:
+
+- `clock.py` — publishes / subscribes a clock track (one frame per second, one group per minute).
+- `announced.py` — lists broadcasts under a prefix as they're announced.
+
+## See also
+
+- Source: [py/moq-rs](https://github.com/moq-dev/moq/tree/main/py/moq-rs)
+- README: [py/moq-rs/README.md](https://github.com/moq-dev/moq/blob/main/py/moq-rs/README.md)
+- Raw bindings: [moq-ffi](https://pypi.org/project/moq-ffi/)
+- The Rust crate this wraps: [moq-net](/lib/rs/crate/moq-net)
diff --git a/doc/lib/rs/crate/hang.md b/doc/lib/rs/crate/hang.md
new file mode 100644
index 0000000000..ae0e8b86cc
--- /dev/null
+++ b/doc/lib/rs/crate/hang.md
@@ -0,0 +1,168 @@
+---
+title: hang
+description: Media library built on moq-net
+---
+
+# hang
+
+[](https://crates.io/crates/hang)
+[](https://docs.rs/hang)
+[](https://github.com/moq-dev/moq/blob/main/LICENSE-MIT)
+
+A media library built on top of [moq-net](/lib/rs/crate/moq-net) for streaming audio and video.
+
+## Overview
+
+`hang` provides media-specific functionality on top of the generic `moq-net` transport:
+
+- **Broadcast** - Discoverable collection of tracks with catalog
+- **Catalog** - Metadata describing available tracks, codec info, etc. (updated live)
+- **Track** - Audio/video streams and other data types
+- **Group** - Group of pictures (video) or collection of samples (audio)
+- **Frame** - Timestamp + codec payload pair
+
+## Installation
+
+Add to your `Cargo.toml`:
+
+```toml
+[dependencies]
+hang = "0.1"
+```
+
+## Supported Codecs
+
+`hang` implements most of the [WebCodecs specification](https://www.w3.org/TR/webcodecs/).
+
+**Video:**
+
+- H.264 (AVC)
+- H.265 (HEVC)
+- VP8
+- VP9
+- AV1
+
+**Audio:**
+
+- AAC
+- Opus
+
+## Quick Start
+
+### Publishing Video
+
+See [`rs/hang/examples/video.rs`](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/video.rs) for a complete example of creating a broadcast with a video track, catalog, and publishing frames.
+
+### Subscribing to Video
+
+See [`rs/hang/examples/subscribe.rs`](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/subscribe.rs) for a complete example of subscribing to a broadcast, reading the catalog, and consuming video frames.
+
+## Catalog
+
+The catalog is a special track containing JSON metadata about available tracks:
+
+```json
+{
+ "version": 1,
+ "tracks": [
+ {
+ "name": "video",
+ "kind": "video",
+ "codec": "avc1.64002a",
+ "width": 1920,
+ "height": 1080,
+ "framerate": 30,
+ "bitrate": 5000000
+ },
+ {
+ "name": "audio",
+ "kind": "audio",
+ "codec": "opus",
+ "sampleRate": 48000,
+ "channelConfig": "2",
+ "bitrate": 128000
+ }
+ ]
+}
+```
+
+The catalog is updated live as tracks are added, removed, or changed.
+
+## Frame Container
+
+Each frame in `hang` consists of a timestamp and codec bitstream payload. See the [video example](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/video.rs) for the `Frame` struct in action.
+
+## CMAF Import
+
+For importing fMP4/CMAF/HLS files, see the [moq-mux](/lib/rs/crate/moq-mux) crate.
+
+## Grouping
+
+Groups are aligned with natural boundaries:
+
+**Video:**
+
+- Start with keyframe (I-frame)
+- Include dependent frames (P/B-frames)
+- Enable joining at group boundaries
+
+**Audio:**
+
+- Collection of audio packets
+- Usually 1 second of audio
+- Independent decoding
+
+See the [video example](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/video.rs) for grouping with `OrderedProducer`.
+
+## Prioritization
+
+`hang` automatically prioritizes:
+
+1. **Keyframes** - Highest priority (can't decode without them)
+2. **Recent frames** - Higher priority than old frames
+3. **Audio** - Often prioritized over video
+
+This is handled automatically based on frame metadata.
+
+## CLI Tool
+
+The `moq-cli` package provides a command-line tool (binary name: `moq-cli`):
+
+```bash
+# Install
+cargo install moq-cli
+
+# Publish a video file
+moq-cli publish video.mp4
+
+# Publish from FFmpeg
+ffmpeg -i input.mp4 -f mpegts - | moq-cli publish -
+
+# Custom encoding settings
+moq-cli publish --codec h264 --bitrate 2000000 video.mp4
+```
+
+See `moq-cli --help` for all options, or [FFmpeg documentation](/bin/cli).
+
+## API Reference
+
+Full API documentation: [docs.rs/hang](https://docs.rs/hang)
+
+Key types:
+
+- `Broadcast` - Media broadcast with catalog
+- `Catalog` - Track metadata
+- `VideoConfig` / `AudioConfig` - Track configuration
+- `Frame` - Timestamp + codec bitstream
+- [moq-mux](/lib/rs/crate/moq-mux) - CMAF/fMP4/HLS import
+
+## Protocol Specification
+
+See the [hang specification](https://datatracker.ietf.org/doc/draft-lcurley-moq-hang/) for protocol details.
+
+## Next Steps
+
+- Use the [moq-net](/lib/rs/crate/moq-net) transport layer
+- Deploy a [relay server](/bin/relay/)
+- Read the [Concepts guide](/concept/)
+- View [code examples](https://github.com/moq-dev/moq/tree/main/rs)
diff --git a/doc/lib/rs/crate/index.md b/doc/lib/rs/crate/index.md
new file mode 100644
index 0000000000..acf39fff06
--- /dev/null
+++ b/doc/lib/rs/crate/index.md
@@ -0,0 +1,18 @@
+---
+title: Crates
+description: Rust crates in the MoQ project
+---
+
+# Crates
+
+Rust crates providing the MoQ protocol implementation and related tooling.
+
+| Crate | Description |
+|-------|-------------|
+| [moq-net](./moq-net) | Networking layer (pub/sub on QUIC) |
+| [moq-native](./moq-native) | QUIC/WebTransport helpers for native apps |
+| [moq-token](./moq-token) | JWT authentication library |
+| [hang](./hang) | Media encoding/streaming (catalog, container) |
+| [web-transport](./web-transport) | WebTransport protocol support |
+| [moq-mux](./moq-mux) | Media muxers/demuxers (fMP4, CMAF, HLS) |
+| [libmoq](./libmoq) | C FFI bindings |
diff --git a/doc/lib/rs/crate/libmoq.md b/doc/lib/rs/crate/libmoq.md
new file mode 100644
index 0000000000..946b52ffce
--- /dev/null
+++ b/doc/lib/rs/crate/libmoq.md
@@ -0,0 +1,57 @@
+---
+title: libmoq
+description: C bindings for MoQ
+---
+
+# libmoq
+
+[](https://docs.rs/libmoq)
+
+C bindings for `moq-net` via FFI, enabling MoQ integration in C/C++ applications and other languages.
+
+## Overview
+
+`libmoq` provides:
+
+- **C API** - Header files for C integration
+- **FFI bindings** - Safe Rust-to-C interface
+- **Build system integration** - CMake and pkg-config support
+
+## Installation
+
+### From Source
+
+```bash
+git clone https://github.com/moq-dev/moq
+cd moq/rs/libmoq
+cargo build --release
+```
+
+The library will be in `target/release/libmoq.a` (static) or `target/release/libmoq.so` (dynamic).
+
+### Using Cargo
+
+```bash
+cargo install libmoq
+```
+
+## Usage
+
+See the [`rs/libmoq/README.md`](https://github.com/moq-dev/moq/blob/main/rs/libmoq/README.md) for the C API function signatures and the [`rs/libmoq/src/test.rs`](https://github.com/moq-dev/moq/blob/main/rs/libmoq/src/test.rs) for working usage examples.
+
+## API Reference
+
+Full API documentation: [docs.rs/libmoq](https://docs.rs/libmoq)
+
+## Use Cases
+
+- **C/C++ applications** - Native integration without Rust toolchain
+- **Language bindings** - Build bindings for Python, Go, etc.
+- **Legacy systems** - Integrate MoQ into existing C codebases
+- **Embedded systems** - Where Rust runtime isn't available
+
+## Next Steps
+
+- Use [moq-net](/lib/rs/crate/moq-net) for Rust applications
+- Deploy a [relay server](/bin/relay/)
+- Read the [Concepts guide](/concept/)
diff --git a/doc/lib/rs/crate/moq-boy.md b/doc/lib/rs/crate/moq-boy.md
new file mode 100644
index 0000000000..10f39e2740
--- /dev/null
+++ b/doc/lib/rs/crate/moq-boy.md
@@ -0,0 +1,42 @@
+---
+title: moq-boy
+description: Crowd-controlled Game Boy Color emulator that streams over MoQ
+---
+
+# moq-boy
+
+[](https://github.com/moq-dev/moq/blob/main/LICENSE-MIT)
+
+A Rust binary that runs a Game Boy Color emulator, encodes its framebuffer and audio as MoQ tracks, and accepts button input from any number of viewers.
+
+This is the server side of the [MoQ Boy demo](/demo/moq-boy). See that page for the architecture breakdown — broadcast layout, tracks, auto-pause, etc. The browser-side counterpart is [@moq/boy](/lib/js/@moq/boy).
+
+## Highlights
+
+- **On-demand emulation** — the emulator only runs while at least one viewer is subscribed to the video track.
+- **Hang container format** — video and audio flow through [moq-mux](/lib/rs/crate/moq-mux) so standard `hang` players can consume them.
+- **Raw JSON metadata** — `status` and `command` tracks bypass the container and publish JSON directly to [moq-net](/lib/rs/crate/moq-net) groups.
+
+## Running Locally
+
+```bash
+just demo boy
+```
+
+This starts a localhost relay, the emulator, and a Vite dev server. A default ROM is downloaded on first run. To load a custom ROM:
+
+```bash
+just demo boy start path/to/game.gb
+```
+
+See the [setup guide](/setup/demo/boy) for controls, reset behavior, and the authenticated/anonymous prefix split.
+
+## Source
+
+[rs/moq-boy](https://github.com/moq-dev/moq/tree/main/rs/moq-boy)
+
+## Next Steps
+
+- Read the [demo architecture](/demo/moq-boy) for tracks, broadcast layout, and how discovery works
+- See the [@moq/boy](/lib/js/@moq/boy) package for the browser player
+- See [moq-net](/lib/rs/crate/moq-net) for the transport layer and [moq-mux](/lib/rs/crate/moq-mux) for the container format
diff --git a/doc/lib/rs/crate/moq-mux.md b/doc/lib/rs/crate/moq-mux.md
new file mode 100644
index 0000000000..f799dd0f76
--- /dev/null
+++ b/doc/lib/rs/crate/moq-mux.md
@@ -0,0 +1,101 @@
+---
+title: moq-mux
+description: Media muxers and demuxers for MoQ
+---
+
+# moq-mux
+
+[](https://crates.io/crates/moq-mux)
+[](https://docs.rs/moq-mux)
+[](https://github.com/moq-dev/moq/blob/main/LICENSE-MIT)
+
+Media muxers and demuxers for converting existing media formats into MoQ broadcasts.
+
+## Overview
+
+`moq-mux` provides tools for importing media from various container formats:
+
+- **fMP4/CMAF** - Fragmented MP4 and Common Media Application Format
+- **HLS** - HTTP Live Streaming playlists
+- **Annex B** - H.264/H.265 raw NAL unit streams
+
+This crate is designed for ingesting existing content into the MoQ ecosystem, converting from traditional formats into [hang](/lib/rs/crate/hang) broadcasts.
+
+## Installation
+
+Add to your `Cargo.toml`:
+
+```toml
+[dependencies]
+moq-mux = "0.1"
+```
+
+### Feature Flags
+
+| Feature | Default | Description |
+|---------|---------|-------------|
+| `mp4` | ✓ | fMP4/CMAF support |
+| `h264` | ✓ | H.264 codec support |
+| `h265` | ✓ | H.265 codec support |
+| `hls` | ✓ | HLS playlist import |
+
+## Quick Start
+
+### Import fMP4 / HLS
+
+See the [moq-cli source](https://github.com/moq-dev/moq/tree/main/rs/moq-cli) for real-world usage of `moq-mux` for importing fMP4 and HLS streams.
+
+## Supported Codecs
+
+**Video:**
+
+- H.264 (AVC) - requires `h264` feature
+- H.265 (HEVC) - requires `h265` feature
+
+**Audio:**
+
+- AAC
+- Opus
+
+## Use Cases
+
+- **Ingest existing content** - Convert VOD files to MoQ broadcasts
+- **HLS bridge** - Re-publish HLS streams over MoQ for lower latency
+- **Testing** - Use sample files for development and testing
+- **Migration** - Transition from traditional streaming to MoQ
+
+## Integration with hang
+
+`moq-mux` produces [hang](/lib/rs/crate/hang) broadcasts with proper catalog and frame metadata. See the [hang video example](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/video.rs) for how to publish a broadcast with proper catalog setup.
+
+## API Reference
+
+Full API documentation: [docs.rs/moq-mux](https://docs.rs/moq-mux)
+
+Key types:
+
+- `Fmp4` - fMP4/CMAF importer
+- `Fmp4Config` - Configuration for fMP4 import
+- `Hls` - HLS playlist importer
+- `Decoder` - Codec-specific decoders (AAC, Opus, AVC, HEVC)
+
+## CLI Tool
+
+For command-line importing, use [moq-cli](/bin/cli):
+
+```bash
+# Install
+cargo install moq-cli
+
+# Publish a video file
+moq-cli publish video.mp4
+
+# Publish from FFmpeg
+ffmpeg -i input.mp4 -f mpegts - | moq-cli publish -
+```
+
+## Next Steps
+
+- Use [hang](/lib/rs/crate/hang) for media encoding/decoding
+- Use [moq-net](/lib/rs/crate/moq-net) for the transport layer
+- Deploy a [relay server](/bin/relay/)
diff --git a/doc/lib/rs/crate/moq-native.md b/doc/lib/rs/crate/moq-native.md
new file mode 100644
index 0000000000..3a3dc2b774
--- /dev/null
+++ b/doc/lib/rs/crate/moq-native.md
@@ -0,0 +1,36 @@
+---
+title: moq-native
+description: QUIC/WebTransport connection helpers for native Rust apps
+---
+
+# moq-native
+
+[](https://crates.io/crates/moq-native)
+[](https://docs.rs/moq-native)
+
+QUIC and WebTransport connection helpers for native Rust applications. Provides TLS configuration, certificate management, and connection establishment utilities used by the relay server and CLI tools.
+
+## Overview
+
+`moq-native` bridges the gap between the transport-agnostic `moq-net` crate and actual QUIC/WebTransport networking. It handles:
+
+- TLS certificate loading and configuration
+- QUIC connection setup via [quinn](https://crates.io/crates/quinn)
+- WebTransport session management
+- Development certificate generation for local testing
+
+## Installation
+
+```toml
+[dependencies]
+moq-native = "0.1"
+```
+
+## API Reference
+
+Full API documentation: [docs.rs/moq-native](https://docs.rs/moq-native)
+
+## Next Steps
+
+- Build with [moq-net](/lib/rs/crate/moq-net) for the core pub/sub protocol
+- Deploy a [relay server](/bin/relay/)
diff --git a/doc/lib/rs/crate/moq-net.md b/doc/lib/rs/crate/moq-net.md
new file mode 100644
index 0000000000..ba9dfa19d8
--- /dev/null
+++ b/doc/lib/rs/crate/moq-net.md
@@ -0,0 +1,64 @@
+---
+title: moq-net
+description: Real-time pub/sub with caching, fan-out, and prioritization
+---
+
+# moq-net
+
+[](https://crates.io/crates/moq-net)
+[](https://docs.rs/moq-net)
+[](https://github.com/moq-dev/moq/blob/main/LICENSE-MIT)
+
+The networking layer for Media over QUIC: real-time pub/sub with built-in caching, fan-out, and prioritization, on top of QUIC. At session setup it negotiates one of two wire protocols: the simplified [moq-lite](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/) protocol or the full IETF [moq-transport](https://datatracker.ietf.org/group/moq/documents/) protocol.
+
+> Previously published as `moq-lite`; renamed to clarify that this is the networking layer, not a specific wire protocol.
+
+## Overview
+
+`moq-net` provides the networking layer for MoQ, implementing broadcasts, tracks, groups, and frames on top of QUIC. Live media is built on top of this layer using something like [hang](/lib/rs/crate/hang).
+
+## Core Concepts
+
+- **Broadcasts** — Discoverable collections of tracks
+- **Tracks** — Named streams of data, split into groups
+- **Groups** — Sequential collections of frames, independently decodable
+- **Frames** — Timed chunks of data
+
+## Installation
+
+Add to your `Cargo.toml`:
+
+```toml
+[dependencies]
+moq-net = "0.1"
+```
+
+## API Reference
+
+The Rust API uses a builder pattern with `Session`, `OriginProducer`/`OriginConsumer`, and related types. See the full API documentation for details:
+
+**[docs.rs/moq-net](https://docs.rs/moq-net)**
+
+## Features
+
+- Multiple tracks can be published/subscribed simultaneously
+- Groups are delivered over independent QUIC streams
+- Built-in deduplication for shared subscriptions
+- QUIC stream prioritization for important data
+- Partial reliability, old groups can be dropped to maintain real-time latency
+
+## Authentication
+
+Pass JWT tokens via query parameters in the URL. See [Authentication guide](/bin/relay/auth) for details.
+
+## Protocol Specification
+
+See the [moq-lite specification](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/) and [moq-transport drafts](https://datatracker.ietf.org/group/moq/documents/) for the wire formats that this crate speaks.
+
+## Next Steps
+
+- Build media apps with [hang](/lib/rs/crate/hang)
+- Use [moq-native](/lib/rs/crate/moq-native) for QUIC/WebTransport connection helpers
+- Deploy a [relay server](/bin/relay/)
+- Read the [Concepts guide](/concept/)
+- View [code examples](https://github.com/moq-dev/moq/tree/main/rs/moq-native/examples)
diff --git a/doc/lib/rs/crate/moq-token.md b/doc/lib/rs/crate/moq-token.md
new file mode 100644
index 0000000000..f351dbc792
--- /dev/null
+++ b/doc/lib/rs/crate/moq-token.md
@@ -0,0 +1,158 @@
+---
+title: moq-token
+description: JWT authentication library for MoQ
+---
+
+# moq-token
+
+[](https://crates.io/crates/moq-token)
+[](https://docs.rs/moq-token)
+
+JWT authentication library and CLI tool for MoQ relay authentication.
+
+## Overview
+
+`moq-token` provides:
+
+- **Library** - Generate and verify JWT tokens in Rust
+- **CLI** - Command-line tool for key and token management
+- **Multiple algorithms** - HMAC, RSA, ECDSA, EdDSA
+
+## Installation
+
+### Library
+
+Add to your `Cargo.toml`:
+
+```toml
+[dependencies]
+moq-token = "0.1"
+```
+
+### CLI
+
+```bash
+cargo install moq-token-cli
+```
+
+The binary is named `moq-token-cli`.
+
+#### Using Nix
+
+```bash
+# Run directly
+nix run github:moq-dev/moq#moq-token-cli
+
+# Or build and find the binary in ./result/bin/
+nix build github:moq-dev/moq#moq-token-cli
+```
+
+#### Using Docker
+
+```bash
+docker pull moqdev/moq-token-cli
+docker run -v "$(pwd):/app" -w /app moqdev/moq-token-cli --key root.jwk generate
+```
+
+Multi-arch images (`linux/amd64` and `linux/arm64`) are published to [Docker Hub](https://hub.docker.com/r/moqdev/moq-token-cli).
+
+## CLI Usage
+
+### Generate a Key
+
+```bash
+# Symmetric key (HMAC)
+moq-token-cli generate --out root.jwk --algorithm HS256
+
+# Asymmetric key pair (RSA)
+moq-token-cli generate --algorithm RS256 --out private.jwk --public public.jwk
+
+# Asymmetric key pair (EdDSA)
+moq-token-cli generate --algorithm EdDSA --out private.jwk --public public.jwk
+```
+
+### Sign a Token
+
+```bash
+moq-token-cli sign --key root.jwk \
+ --root "rooms/123" \
+ --publish "alice" \
+ --subscribe "" \
+ --expires 1735689600 > alice.jwt
+```
+
+### Verify a Token
+
+```bash
+moq-token-cli verify --key root.jwk < alice.jwt
+```
+
+## Supported Algorithms
+
+**Symmetric (HMAC):**
+
+- HS256
+- HS384
+- HS512
+
+**Asymmetric (RSA):**
+
+- RS256, RS384, RS512
+- PS256, PS384, PS512
+
+**Asymmetric (Elliptic Curve):**
+
+- EC256, EC384
+- EdDSA
+
+## Library Usage
+
+- [`rs/moq-token/examples/basic.rs`](https://github.com/moq-dev/moq/blob/main/rs/moq-token/examples/basic.rs) - Generate a symmetric key, sign a token, verify it, and round-trip the key
+- [`rs/moq-token/examples/asymmetric.rs`](https://github.com/moq-dev/moq/blob/main/rs/moq-token/examples/asymmetric.rs) - Generate an ECDSA key pair, extract the public key for the relay, sign and verify
+
+For the TypeScript equivalent, see [`js/token/examples/sign-and-verify.ts`](https://github.com/moq-dev/moq/blob/main/js/token/examples/sign-and-verify.ts).
+
+## Token Claims
+
+| Claim | Type | Description |
+|-------|------|-------------|
+| `root` | string | Root path for all operations |
+| `put` | `string \| string[]?` | Publishing permission paths |
+| `get` | `string \| string[]?` | Subscription permission paths |
+| `cluster` | bool? | Cluster node flag |
+| `exp` | number? | Expiration (Unix timestamp) |
+| `iat` | number? | Issued at (Unix timestamp) |
+
+## Integration with moq-relay
+
+Configure the relay to use your key:
+
+```toml
+[auth]
+key = "root.jwk"
+public = "anon" # Optional: anonymous access
+```
+
+See [Relay Authentication](/bin/relay/auth) for details.
+
+## Security Considerations
+
+- **Symmetric keys** should only be used when the same entity signs and verifies
+- **Asymmetric keys** are preferred for distributed systems (relay only needs public key)
+- **Token expiration** should be set appropriately for your use case
+- **Secure transmission** - Only transmit tokens over HTTPS
+- **Secure storage** - Keep private keys secure
+
+## JWK Set Support
+
+For key rotation, use the relay's `key_dir` option pointing to a directory or URL. The relay resolves keys on demand by extracting the `kid` (key ID) from the JWT header and fetching the corresponding `{kid}.jwk` file. See [Relay Authentication](/bin/relay/auth) for configuration details.
+
+## API Reference
+
+Full API documentation: [docs.rs/moq-token](https://docs.rs/moq-token)
+
+## Next Steps
+
+- Configure [Relay Authentication](/bin/relay/auth)
+- Deploy a [Relay Server](/bin/relay/)
+- Learn about [Authentication](/bin/relay/auth)
diff --git a/doc/lib/rs/crate/web-transport.md b/doc/lib/rs/crate/web-transport.md
new file mode 100644
index 0000000000..648dcde8c4
--- /dev/null
+++ b/doc/lib/rs/crate/web-transport.md
@@ -0,0 +1,71 @@
+---
+title: web-transport
+description: QUIC and WebTransport implementation for Rust
+---
+
+# web-transport
+
+QUIC and WebTransport implementation for Rust, providing the networking layer for MoQ.
+
+::: info External Repository
+This crate is maintained in a separate repository: [moq-dev/web-transport](https://github.com/moq-dev/web-transport)
+:::
+
+## Overview
+
+The `web-transport` crate provides:
+
+- **QUIC client and server** - Built on Quinn
+- **WebTransport protocol** - HTTP/3 based transport
+- **Browser compatibility** - Same protocol as browser WebTransport API
+- **TLS management** - Certificate handling utilities
+
+## Repository
+
+**GitHub:** [moq-dev/web-transport](https://github.com/moq-dev/web-transport)
+
+## Crates
+
+The repository contains multiple crates:
+
+| Crate | Description |
+|-------|-------------|
+| `web-transport` | Core WebTransport implementation |
+| `web-transport-quinn` | Quinn-based QUIC transport |
+| `web-transport-ws` | WebSocket polyfill for non-WebTransport browsers |
+
+## Installation
+
+Add to your `Cargo.toml`:
+
+```toml
+[dependencies]
+web-transport = "0.1"
+web-transport-quinn = "0.1"
+```
+
+## Quick Start
+
+### Client & Server
+
+See the [web-transport repository](https://github.com/moq-dev/web-transport) for client and server examples.
+
+For a real-world example of using `web-transport` with MoQ, see the [`rs/moq-native/examples/chat.rs`](https://github.com/moq-dev/moq/blob/main/rs/moq-native/examples/chat.rs) example which demonstrates connection setup, publishing, and session management.
+
+## Features
+
+- **Streams** - Bidirectional and unidirectional streams
+- **Datagrams** - Unreliable, unordered data
+- **Session Management** - Peer/local addresses, graceful close
+- **TLS** - Self-signed certificates (dev), Let's Encrypt (production), certificate fingerprints
+- **WebSocket Polyfill** - See [web-transport-ws](https://github.com/moq-dev/web-transport/tree/main/rs/web-transport-ws)
+
+## Integration with MoQ
+
+The `moq-net` crate uses `web-transport` internally. See the [moq-native examples](https://github.com/moq-dev/moq/tree/main/rs/moq-native/examples) for how connections are established.
+
+## Next Steps
+
+- Check the [GitHub repository](https://github.com/moq-dev/web-transport)
+- Use [moq-net](/lib/rs/crate/moq-net) for MoQ protocol
+- Deploy a [relay server](/bin/relay/)
diff --git a/doc/lib/rs/env/index.md b/doc/lib/rs/env/index.md
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/doc/lib/rs/env/native.md b/doc/lib/rs/env/native.md
new file mode 100644
index 0000000000..96d7915a2b
--- /dev/null
+++ b/doc/lib/rs/env/native.md
@@ -0,0 +1,198 @@
+---
+title: Native
+description: Building native MoQ clients in Rust for desktop, mobile, and embedded.
+---
+
+# Native
+
+Build native MoQ clients in Rust for desktop, mobile, and embedded platforms.
+This guide covers connecting to a relay, discovering broadcasts, subscribing to media tracks, and decoding frames.
+
+## Dependencies
+
+The key crates:
+
+- [moq-native](https://crates.io/crates/moq-native) — Configures QUIC (via [quinn](https://crates.io/crates/quinn)) and TLS (via [rustls](https://crates.io/crates/rustls)) for you.
+- [moq-net](https://crates.io/crates/moq-net) — The core networking layer. Can be used directly with any `web_transport_trait::Session` implementation if you need full control over the QUIC endpoint.
+- [hang](https://crates.io/crates/hang) — Media-specific catalog and container format on top of `moq-net`.
+
+## Connecting
+
+Create a [`ClientConfig`](https://docs.rs/moq-native/latest/moq_native/struct.ClientConfig.html) and connect to a relay:
+
+```rust
+let client = moq_native::ClientConfig::default().init()?;
+let url = url::Url::parse("https://cdn.moq.dev/anon/my-broadcast")?;
+let session = client.connect(url).await?;
+```
+
+The default configuration uses system TLS roots, enables WebSocket fallback, and gives QUIC a 200ms head-start.
+
+### URL Schemes
+
+The client supports several URL schemes:
+
+- `https://` — WebTransport over HTTP/3 (recommended for browsers and native)
+- `http://` — Local development with self-signed certs (fetches the certificate fingerprint automatically)
+- `moqt://` — Raw QUIC with the MoQ IETF ALPN (no WebTransport overhead)
+- `moql://` — Raw QUIC with the moq-lite ALPN
+
+### Transport Racing
+
+`client.connect()` automatically races QUIC and WebSocket connections.
+QUIC gets a configurable head-start (default 200ms); if it fails, WebSocket takes over.
+Once WebSocket wins for a given server, future connections skip the delay.
+This is transparent to your application.
+
+### Authentication
+
+Pass JWT tokens via URL query parameters:
+
+```rust
+let url = Url::parse(&format!(
+ "https://relay.example.com/room/123?jwt={}", token
+))?;
+let session = client.connect(url).await?;
+```
+
+See the [Authentication guide](/bin/relay/auth) for how to generate tokens.
+
+## Publishing
+
+The [video example](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/video.rs) demonstrates publishing end-to-end.
+
+The key pattern is: create an [`Origin`](https://docs.rs/moq-net/latest/moq_net/struct.Origin.html), connect a session to it, then publish broadcasts:
+
+```rust
+let origin = moq_net::Origin::new().produce();
+let session = client
+ .with_publish(origin.consume())
+ .connect(url).await?;
+
+let mut broadcast = moq_net::Broadcast::new().produce();
+// ... add catalog and tracks to the broadcast ...
+origin.publish_broadcast("", broadcast.consume());
+```
+
+See the full [video.rs](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/video.rs) example for catalog setup, track creation, and frame encoding.
+
+## Subscribing
+
+The [subscribe example](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/subscribe.rs) demonstrates subscribing end-to-end.
+
+To consume a broadcast, use `with_consume()` and listen for announcements:
+
+```rust
+let origin = moq_net::Origin::new().produce();
+let mut consumer = origin.consume();
+let session = client
+ .with_consume(origin)
+ .connect(url).await?;
+
+// Wait for broadcasts to be announced.
+while let Some((path, broadcast)) = consumer.announced().await {
+ let Some(broadcast) = broadcast else {
+ tracing::info!(%path, "broadcast ended");
+ continue;
+ };
+ // Subscribe to tracks on this broadcast...
+}
+```
+
+If you already know the broadcast path, you can subscribe directly:
+
+```rust
+let broadcast = consumer.consume_broadcast("my-stream")
+ .expect("broadcast not found");
+```
+
+## Reading the Catalog
+
+The [hang](/concept/layer/hang) catalog describes available media tracks.
+Subscribe to it using [`CatalogConsumer`](https://docs.rs/hang/latest/hang/catalog/struct.CatalogConsumer.html):
+
+```rust
+let catalog_track = broadcast.subscribe_track(&hang::Catalog::default_track());
+let mut catalog = hang::CatalogConsumer::new(catalog_track);
+let info = catalog.next().await?.expect("no catalog");
+```
+
+The catalog is live-updated — call `catalog.next().await` again to receive updates when tracks change.
+
+See the full [subscribe.rs](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/subscribe.rs) example for iterating renditions and selecting a track.
+
+## Reading Frames
+
+Subscribe to a media track and read frames using [`OrderedConsumer`](https://docs.rs/hang/latest/hang/container/struct.OrderedConsumer.html):
+
+```rust
+let track_consumer = broadcast.subscribe_track(&track);
+let mut ordered = hang::container::OrderedConsumer::new(
+ track_consumer,
+ Duration::from_millis(500), // max latency before skipping groups
+);
+
+while let Some(frame) = ordered.read().await? {
+ // frame.timestamp, frame.keyframe, frame.payload
+}
+```
+
+`OrderedConsumer` handles group ordering and latency management automatically.
+Groups that fall too far behind are skipped to maintain real-time playback.
+
+## Platform Decoders
+
+The frame payload contains the raw codec bitstream.
+You need a platform decoder to turn it into pixels or audio samples.
+
+### Video
+
+- **macOS/iOS** — VideoToolbox (`VTDecompressionSession`). Feed H.264 NALs wrapped in `CMSampleBuffer`.
+- **Android** — `MediaCodec` via NDK. Feed NAL units directly.
+- **Linux** — VA-API via `libva`, or GStreamer for a higher-level API.
+- **Cross-platform** — FFmpeg via the `ffmpeg-next` crate works everywhere.
+
+### Audio
+
+For AAC-LC audio, [symphonia](https://crates.io/crates/symphonia) decodes to PCM samples and [cpal](https://crates.io/crates/cpal) handles platform audio output.
+For Opus, symphonia also supports decoding, or use the `opus` crate directly.
+
+Use a ring buffer between the decoder and audio output to absorb network jitter.
+
+## Common Pitfalls
+
+### `description` Field in the Catalog
+
+Both `VideoConfig` and `AudioConfig` have a `description` field that provides out-of-band codec initialization data. If present, it contains codec-specific configuration as a hex-encoded byte string.
+
+**Video examples:**
+
+- **H.264** — SPS/PPS in AVCC format. NAL units in the payload are length-prefixed.
+- **H.265** — VPS/SPS/PPS in HVCC format.
+
+**Audio examples:**
+
+- **AAC** — `AudioSpecificConfig` bytes.
+- **Opus** — Typically `None`; configuration is in-band.
+
+When `description` is `None`, codec parameters are delivered in-band (e.g. Annex B start codes `00 00 00 01` or `00 00 01` for H.264/H.265).
+Your decoder must handle whichever format the publisher uses.
+See the [hang format docs](/concept/layer/hang) for details.
+
+### Container Format
+
+Check the `container` field for each rendition:
+
+- **`legacy`** — Each frame is a varint timestamp (microseconds) followed by the codec payload. This is the common case.
+- **`cmaf`** — Each frame is a `moof` + `mdat` pair (fragmented MP4). Used for HLS compatibility.
+
+`OrderedConsumer` decodes legacy timestamps for you automatically.
+
+## Next Steps
+
+- [hang format](/concept/layer/hang) — Catalog schema and container details
+- [moq-net docs](https://docs.rs/moq-net) — Core networking API reference
+- [moq-native docs](https://docs.rs/moq-native) — Client configuration options
+- [Relay HTTP endpoints](/bin/relay/http) — HTTP fetch for debugging and late-join
+- [video.rs](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/video.rs) — Complete publishing example
+- [subscribe.rs](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/subscribe.rs) — Complete subscribing example
diff --git a/doc/lib/rs/env/wasm.md b/doc/lib/rs/env/wasm.md
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md
new file mode 100644
index 0000000000..9fae8ca9c8
--- /dev/null
+++ b/doc/lib/rs/index.md
@@ -0,0 +1,279 @@
+---
+title: Rust Libraries
+description: Rust implementation of MoQ protocol and tools
+---
+
+# Rust Libraries
+
+The Rust implementation provides the reference implementation of the MoQ protocol, along with server-side tools and native applications.
+
+## Core Libraries
+
+### moq-net
+
+[](https://crates.io/crates/moq-net)
+[](https://docs.rs/moq-net)
+
+The networking layer for MoQ. At session setup it negotiates one of two wire protocols: the simplified [moq-lite](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/) protocol or the full IETF [moq-transport](https://datatracker.ietf.org/group/moq/documents/) protocol.
+
+**Features:**
+
+- Broadcasts, tracks, groups, and frames
+- Built-in concurrency and deduplication
+- QUIC stream management
+- Prioritization and backpressure
+
+[Learn more](/lib/rs/crate/moq-net)
+
+### hang
+
+[](https://crates.io/crates/hang)
+[](https://docs.rs/hang)
+
+Media-specific encoding/streaming library built on top of `moq-net`.
+
+**Features:**
+
+- Catalog for track discovery
+- Container format (timestamp + codec bitstream)
+- Support for H.264/265, VP8/9, AV1, AAC, Opus
+
+[Learn more](/lib/rs/crate/hang)
+
+### moq-mux
+
+[](https://crates.io/crates/moq-mux)
+[](https://docs.rs/moq-mux)
+
+Media muxers and demuxers for importing existing formats into MoQ.
+
+**Features:**
+
+- fMP4/CMAF import
+- HLS playlist import
+- H.264/H.265 Annex B parsing
+- AAC and Opus codec support
+
+[Learn more](/lib/rs/crate/moq-mux)
+
+## Authentication
+
+### moq-token
+
+[](https://crates.io/crates/moq-token)
+[](https://docs.rs/moq-token)
+
+JWT authentication library and CLI tool for generating tokens.
+
+**Features:**
+
+- HMAC and RSA/ECDSA signing
+- Path-based authorization
+- Token generation and verification
+- Available as library and CLI
+
+[Learn more](/lib/rs/crate/moq-token)
+
+## Networking
+
+### web-transport
+
+QUIC and WebTransport implementation for Rust.
+
+**Features:**
+
+- Quinn-based QUIC
+- WebTransport protocol support
+- TLS certificate management
+- Server and client modes
+
+[Learn more](/lib/rs/crate/web-transport)
+
+### moq-native
+
+[](https://docs.rs/moq-native)
+
+Opinionated helpers to configure a Quinn QUIC endpoint.
+
+**Features:**
+
+- TLS certificate management
+- QUIC transport configuration
+- Connection setup helpers
+
+## CLI Tools
+
+### moq-cli
+
+Command-line tool for media operations (binary name: `moq-cli`).
+
+**Features:**
+
+- Publish video from files or FFmpeg
+- Test and development
+- Media server deployments
+
+**Installation:**
+
+```bash
+cargo install moq-cli
+```
+
+**Usage:**
+
+```bash
+# Publish a video file
+moq-cli publish video.mp4
+
+# Publish from FFmpeg
+ffmpeg -i input.mp4 -f mpegts - | moq-cli publish -
+```
+
+[Learn more](/bin/cli)
+
+### moq-token-cli
+
+Command-line tool for JWT token management (binary name: `moq-token-cli`).
+
+**Installation:**
+
+```bash
+cargo install moq-token-cli
+```
+
+**Usage:**
+
+```bash
+# Generate a key
+moq-token-cli --key root.jwk generate
+
+# Sign a token
+moq-token-cli --key root.jwk sign \
+ --root "rooms/123" \
+ --publish "alice" \
+ --expires 1735689600
+```
+
+See [Authentication guide](/bin/relay/auth) for details.
+
+## Utilities
+
+### libmoq
+
+[](https://docs.rs/libmoq)
+
+C bindings for `moq-net` via FFI.
+
+**Use cases:**
+
+- Integrate with C/C++ applications
+- Bindings for other languages
+- Legacy system integration
+
+## Installation
+
+### From crates.io
+
+Add to your `Cargo.toml`:
+
+```toml
+[dependencies]
+moq-net = "0.1"
+hang = "0.1"
+```
+
+### From Source
+
+```bash
+git clone https://github.com/moq-dev/moq
+cd moq/rs
+cargo build --release
+```
+
+### Using Nix
+
+```bash
+# Build moq-relay
+nix build github:moq-dev/moq#moq-relay
+
+# Build moq-cli
+nix build github:moq-dev/moq#moq-cli
+```
+
+## Quick Start
+
+### Publishing (Rust)
+
+```rust
+use moq_net::*;
+use tokio;
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ // Connect to relay
+ let connection = Connection::connect("https://relay.example.com/demo").await?;
+
+ // Create a broadcast
+ let mut broadcast = BroadcastProducer::new("my-broadcast");
+
+ // Create a track
+ let mut track = broadcast.create_track("chat");
+
+ // Publish a group with a frame
+ let mut group = track.append_group();
+ group.write(b"Hello, MoQ!")?;
+ group.close()?;
+
+ // Publish to connection
+ connection.publish(&mut broadcast).await?;
+
+ Ok(())
+}
+```
+
+### Subscribing (Rust)
+
+```rust
+use moq_net::*;
+use tokio;
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ // Connect to relay
+ let connection = Connection::connect("https://relay.example.com/demo").await?;
+
+ // Subscribe to a broadcast
+ let broadcast = connection.consume("my-broadcast").await?;
+
+ // Subscribe to a track
+ let mut track = broadcast.subscribe("chat").await?;
+
+ // Read groups and frames
+ while let Some(group) = track.recv_group().await? {
+ while let Some(frame) = group.read().await? {
+ println!("Received: {:?}", frame);
+ }
+ }
+
+ Ok(())
+}
+```
+
+## API Documentation
+
+Full API documentation is available on [docs.rs](https://docs.rs):
+
+- [moq-net API](https://docs.rs/moq-net)
+- [hang API](https://docs.rs/hang)
+- [moq-mux API](https://docs.rs/moq-mux)
+- [moq-token API](https://docs.rs/moq-token)
+- [moq-native API](https://docs.rs/moq-native)
+- [libmoq API](https://docs.rs/libmoq)
+
+## Next Steps
+
+- Explore [moq-net](/lib/rs/crate/moq-net) - Networking layer
+- Explore [hang](/lib/rs/crate/hang) - Media library
+- Explore [moq-mux](/lib/rs/crate/moq-mux) - Media import
+- Deploy [moq-relay](/bin/relay/) - Relay server
+- View [code examples](https://github.com/moq-dev/moq/tree/main/rs)
diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md
new file mode 100644
index 0000000000..56ba87deed
--- /dev/null
+++ b/doc/lib/swift/index.md
@@ -0,0 +1,81 @@
+---
+title: Swift Libraries
+description: Swift Package for Media over QUIC on Apple platforms
+---
+
+# Swift Libraries
+
+The Swift bindings expose [Media over QUIC](/) to iOS, iPadOS, macOS, and the iOS Simulator. Built on the same Rust core ([moq-ffi](https://crates.io/crates/moq-ffi)) as the Python and Kotlin packages, wrapped with an idiomatic async/await API.
+
+## Packages
+
+### Moq
+
+A single Swift Package Manager target that wraps the UniFFI bindings with `AsyncSequence` adapters, structured-concurrency-friendly cancellation, and a session helper.
+
+**Features:**
+
+- iOS 15+, iPadOS 15+, macOS 12+
+- Universal binary for Apple Silicon and Intel Macs
+- iOS device + iOS Simulator slices (arm64 and x86_64)
+- Cancellation through Swift `Task` propagates to native consumers
+
+[Learn more](/lib/swift/moq)
+
+## Installation
+
+The package lives in [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift), a mirror repo that SPM resolves with bare-semver tags. Add it to your `Package.swift`:
+
+```swift
+dependencies: [
+ .package(url: "https://github.com/moq-dev/moq-swift", from: "0.2.0"),
+],
+targets: [
+ .target(
+ name: "MyApp",
+ dependencies: [
+ .product(name: "Moq", package: "moq-swift"),
+ ],
+ ),
+]
+```
+
+Or in Xcode: File → Add Package Dependencies → enter the URL.
+
+The package depends on a prebuilt `MoqFFI.xcframework` attached to the matching [`moq-ffi-v*` release](https://github.com/moq-dev/moq/releases) on the source repo. SPM downloads it transparently; no manual asset handling required.
+
+## Quickstart
+
+```swift
+import Moq
+
+// Wire an origin as both publish source and consume sink. Set just one
+// side for a subscribe-only or publish-only client.
+let origin = MoqOriginProducer()
+let client = MoqClient()
+client.setPublish(origin: origin)
+client.setConsume(origin: origin)
+
+let session = try await client.connect(url: "https://relay.example.com")
+
+let consumer = origin.consume()
+let announced = try consumer.announced(prefix: "demos/")
+for try await announcement in announced.announcements {
+ print("got broadcast \(announcement.path())")
+
+ let catalog = try announcement.broadcast().subscribeCatalog()
+ for try await update in catalog.updates {
+ print("catalog: \(update)")
+ }
+}
+
+session.shutdown()
+```
+
+Cancelling the surrounding Swift `Task` propagates through to the underlying `cancel()` calls on each consumer.
+
+## Source and issues
+
+- Source: [swift/](https://github.com/moq-dev/moq/tree/main/swift) (in the monorepo)
+- Mirror (what SPM resolves): [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift)
+- README: [swift/README.md](https://github.com/moq-dev/moq/blob/main/swift/README.md)
diff --git a/doc/lib/swift/moq.md b/doc/lib/swift/moq.md
new file mode 100644
index 0000000000..aade30c4ac
--- /dev/null
+++ b/doc/lib/swift/moq.md
@@ -0,0 +1,121 @@
+---
+title: Moq (Swift)
+description: Swift Package Manager target for Media over QUIC
+---
+
+# Moq
+
+The Swift Package Manager target for [Media over QUIC](/).
+
+This is an ergonomic wrapper around the UniFFI-generated `MoqFFI` types, providing `AsyncSequence` adapters and Swift-friendly errors.
+
+## Install
+
+```swift
+.package(url: "https://github.com/moq-dev/moq-swift", from: "0.2.0"),
+```
+
+Add `Moq` to your target's dependencies:
+
+```swift
+.target(
+ name: "MyApp",
+ dependencies: [
+ .product(name: "Moq", package: "moq-swift"),
+ ],
+),
+```
+
+Supported platforms: iOS 15+, iPadOS 15+, macOS 12+. The package ships an XCFramework with iOS device (arm64), iOS Simulator (arm64 + x86_64), and macOS universal slices.
+
+## Connect
+
+```swift
+import Moq
+
+// Wire an origin as both publish source and consume sink for the
+// typical full-duplex client. Set just one side for a subscribe-only
+// or publish-only client.
+let origin = MoqOriginProducer()
+let client = MoqClient()
+client.setPublish(origin: origin)
+client.setConsume(origin: origin)
+
+let session = try await client.connect(url: "https://relay.example.com")
+```
+
+For development against a relay with a self-signed certificate, configure the client before connecting:
+
+```swift
+let client = MoqClient()
+client.setTlsDisableVerify(disable: true)
+try client.setBind(addr: "127.0.0.1:0")
+client.setPublish(origin: origin)
+client.setConsume(origin: origin)
+let session = try await client.connect(url: "https://localhost:4443")
+```
+
+When you're done, signal graceful shutdown to the peer:
+
+```swift
+session.shutdown() // alias for cancel(code: 0)
+```
+
+## Subscribe
+
+```swift
+let consumer = origin.consume()
+let announced = try consumer.announced(prefix: "demos/")
+
+for try await announcement in announced.announcements {
+ let catalog = try announcement.broadcast().subscribeCatalog()
+ for try await update in catalog.updates {
+ print("catalog: \(update)")
+ }
+}
+```
+
+## Publish
+
+```swift
+let broadcast = try MoqBroadcastProducer()
+let audio = try broadcast.publishMedia(format: "opus", init: opusInitBytes)
+
+try origin.publish(path: "my-stream", broadcast: broadcast)
+
+try audio.writeFrame(payload: payload, timestampUs: 0)
+try audio.writeFrame(payload: payload, timestampUs: 20_000)
+try audio.finish()
+try broadcast.finish()
+```
+
+## Cancellation
+
+All async sequences cooperate with structured concurrency. Cancelling the surrounding `Task` propagates to the underlying `cancel()` call on the consumer:
+
+```swift
+let task = Task {
+ for try await frame in mediaConsumer {
+ process(frame)
+ }
+}
+
+// Later:
+task.cancel() // releases native resources
+```
+
+## Local development
+
+To run the test suite, build a host-only XCFramework first:
+
+```bash
+just check-ffi
+```
+
+This runs `swift/scripts/check.sh`, which builds `moq-ffi` for the host arch, regenerates the UniFFI Swift bindings, drops a single-slice `MoqFFI.xcframework` into `swift/`, and then runs `swift test`. Requires macOS with `xcodebuild`.
+
+## See also
+
+- Source: [swift/Sources/Moq](https://github.com/moq-dev/moq/tree/main/swift/Sources/Moq)
+- Mirror repo: [moq-dev/moq-swift](https://github.com/moq-dev/moq-swift)
+- The Rust crate this wraps: [moq-net](/lib/rs/crate/moq-net)
diff --git a/doc/package.json b/doc/package.json
index 8d216f1228..4916f22548 100644
--- a/doc/package.json
+++ b/doc/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "vitepress dev",
+ "dev": "vitepress dev --open",
"build": "vitepress build",
"preview": "vitepress preview",
"deploy": "vitepress build && wrangler deploy"
diff --git a/doc/public/emoji/back.svg b/doc/public/emoji/back.svg
new file mode 100644
index 0000000000..f67368f301
--- /dev/null
+++ b/doc/public/emoji/back.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/doc/public/emoji/battery.svg b/doc/public/emoji/battery.svg
new file mode 100644
index 0000000000..c44532ebb3
--- /dev/null
+++ b/doc/public/emoji/battery.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/doc/public/emoji/box.svg b/doc/public/emoji/box.svg
new file mode 100644
index 0000000000..6f63014a46
--- /dev/null
+++ b/doc/public/emoji/box.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/doc/public/emoji/globe.svg b/doc/public/emoji/globe.svg
new file mode 100644
index 0000000000..58ab38ee54
--- /dev/null
+++ b/doc/public/emoji/globe.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/doc/public/emoji/link.svg b/doc/public/emoji/link.svg
new file mode 100644
index 0000000000..2a3673d9f6
--- /dev/null
+++ b/doc/public/emoji/link.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/doc/public/emoji/lock.svg b/doc/public/emoji/lock.svg
new file mode 100644
index 0000000000..14930ee8d5
--- /dev/null
+++ b/doc/public/emoji/lock.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/doc/public/emoji/puzzle.svg b/doc/public/emoji/puzzle.svg
new file mode 100644
index 0000000000..9f5c965b66
--- /dev/null
+++ b/doc/public/emoji/puzzle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/doc/public/emoji/rocket.svg b/doc/public/emoji/rocket.svg
new file mode 100644
index 0000000000..fd12f47b6e
--- /dev/null
+++ b/doc/public/emoji/rocket.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/doc/public/emoji/stonk.svg b/doc/public/emoji/stonk.svg
new file mode 100644
index 0000000000..2c8ba8192d
--- /dev/null
+++ b/doc/public/emoji/stonk.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/doc/public/favicon.svg b/doc/public/favicon.svg
new file mode 100644
index 0000000000..4862948168
--- /dev/null
+++ b/doc/public/favicon.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/doc/public/icon.png b/doc/public/icon.png
new file mode 100644
index 0000000000..7ebe70f75e
Binary files /dev/null and b/doc/public/icon.png differ
diff --git a/doc/public/icon.svg b/doc/public/icon.svg
index 4862948168..7f7966c18b 100644
--- a/doc/public/icon.svg
+++ b/doc/public/icon.svg
@@ -1,11 +1,268 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/rust/examples.md b/doc/rust/examples.md
deleted file mode 100644
index e81dee0274..0000000000
--- a/doc/rust/examples.md
+++ /dev/null
@@ -1,349 +0,0 @@
----
-title: Rust Examples
-description: Code examples for Rust libraries
----
-
-# Rust Examples
-
-This page provides code examples for common use cases with the Rust libraries.
-
-## Basic Examples
-
-### Simple Chat Application
-
-A complete example of publishing and subscribing to text messages.
-
-**Source:** [moq-native/examples/chat.rs](https://github.com/moq-dev/moq/blob/main/rs/moq-native/examples/chat.rs)
-
-```rust
-use moq_lite::*;
-use tokio;
-
-#[tokio::main]
-async fn main() -> Result<(), Box> {
- // Connect to relay
- let connection = Connection::connect("https://relay.example.com/chat").await?;
-
- // Create broadcast and track
- let mut broadcast = BroadcastProducer::new("room-123");
- let mut track = broadcast.create_track("messages");
-
- // Publish a message
- let mut group = track.append_group();
- group.write(b"Hello from Rust!")?;
- group.close()?;
-
- connection.publish(&mut broadcast).await?;
-
- Ok(())
-}
-```
-
-### Clock Synchronization
-
-Example of timestamp synchronization between publisher and subscriber.
-
-**Source:** [moq-clock](https://github.com/moq-dev/moq/tree/main/rs/moq-clock)
-
-```rust
-use moq_clock::*;
-use tokio;
-
-#[tokio::main]
-async fn main() -> Result<(), Box> {
- let connection = Connection::connect("https://relay.example.com/demo").await?;
-
- // Publisher side
- let clock_pub = ClockPublisher::new();
- connection.publish(clock_pub.broadcast()).await?;
-
- // Subscriber side
- let broadcast = connection.consume("clock").await?;
- let clock_sub = ClockSubscriber::new(broadcast).await?;
-
- // Get synchronized timestamp
- let timestamp = clock_sub.now();
-
- Ok(())
-}
-```
-
-### Video Publishing
-
-Example of publishing video frames using the hang library.
-
-**Source:** [hang/examples/video.rs](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/video.rs)
-
-```rust
-use hang::*;
-use moq_lite::Connection;
-
-#[tokio::main]
-async fn main() -> Result<(), Box> {
- let connection = Connection::connect("https://relay.example.com/demo").await?;
-
- // Create hang broadcast
- let mut broadcast = Broadcast::new("my-stream");
-
- // Configure video track
- let mut video = broadcast.create_video_track(VideoConfig {
- codec: "avc1.64002a".to_string(),
- width: 1920,
- height: 1080,
- framerate: 30.0,
- bitrate: 5_000_000,
- })?;
-
- // Publish keyframe
- video.append_frame(Frame {
- timestamp: 0,
- data: keyframe_data,
- is_keyframe: true,
- })?;
-
- // Publish delta frames
- video.append_frame(Frame {
- timestamp: 33_333, // ~30fps
- data: delta_frame_data,
- is_keyframe: false,
- })?;
-
- connection.publish_broadcast(broadcast).await?;
-
- Ok(())
-}
-```
-
-## Advanced Examples
-
-### Custom Priority
-
-Set custom priorities for groups:
-
-```rust
-use moq_lite::*;
-
-let mut track = broadcast.create_track("video");
-
-// High priority group (keyframe)
-let mut keyframe_group = track.append_group();
-keyframe_group.set_priority(100);
-keyframe_group.write(keyframe_data)?;
-keyframe_group.close()?;
-
-// Normal priority group
-let mut delta_group = track.append_group();
-delta_group.set_priority(50);
-delta_group.write(delta_frame_data)?;
-delta_group.close()?;
-```
-
-### Partial Reliability
-
-Drop old groups when subscriber is behind:
-
-```rust
-use moq_lite::*;
-use std::time::Duration;
-
-let mut group = track.append_group();
-group.set_expires(Duration::from_secs(2));
-group.write(frame_data)?;
-group.close()?;
-```
-
-If the group isn't delivered within 2 seconds, it will be dropped to maintain real-time latency.
-
-### Error Handling
-
-Handle different error types:
-
-```rust
-use moq_lite::*;
-
-match connection.publish(&mut broadcast).await {
- Ok(()) => println!("Published successfully"),
- Err(Error::ConnectionClosed) => {
- eprintln!("Connection closed, reconnecting...");
- // Implement reconnection logic
- }
- Err(Error::InvalidPath(path)) => {
- eprintln!("Invalid path: {}", path);
- // Fix the path
- }
- Err(Error::PermissionDenied) => {
- eprintln!("Permission denied, check JWT token");
- // Request new token
- }
- Err(e) => {
- eprintln!("Unexpected error: {}", e);
- return Err(e.into());
- }
-}
-```
-
-### Multi-Track Publishing
-
-Publish multiple tracks in one broadcast:
-
-```rust
-use hang::*;
-
-let mut broadcast = Broadcast::new("conference");
-
-// Video track
-let mut video = broadcast.create_video_track(VideoConfig {
- codec: "avc1.64002a".to_string(),
- width: 1280,
- height: 720,
- framerate: 30.0,
- bitrate: 2_500_000,
-})?;
-
-// Audio track
-let mut audio = broadcast.create_audio_track(AudioConfig {
- codec: "opus".to_string(),
- sample_rate: 48000,
- channels: 2,
- bitrate: 128_000,
-})?;
-
-// Chat track (text)
-let mut chat = broadcast.create_track("chat");
-
-// Publish frames to each track
-video.append_frame(video_frame)?;
-audio.append_frame(audio_packet)?;
-chat.append_group().write(b"Hello!")?;
-
-connection.publish_broadcast(broadcast).await?;
-```
-
-### Subscribing to Multiple Tracks
-
-Subscribe to all tracks in a broadcast:
-
-```rust
-use hang::*;
-
-let connection = Connection::connect("https://relay.example.com/demo").await?;
-let broadcast = connection.consume_broadcast("conference").await?;
-
-// Read catalog to discover tracks
-let catalog = broadcast.catalog().await?;
-
-// Spawn tasks for each track
-for track_info in catalog.tracks {
- let broadcast_clone = broadcast.clone();
-
- tokio::spawn(async move {
- let mut track = broadcast_clone.subscribe(&track_info.name).await?;
-
- while let Some(frame) = track.next_frame().await? {
- match track_info.kind.as_str() {
- "video" => handle_video_frame(frame),
- "audio" => handle_audio_frame(frame),
- _ => handle_other_frame(frame),
- }
- }
-
- Ok::<(), Error>(())
- });
-}
-```
-
-### CMAF Import
-
-Import existing fMP4/CMAF files:
-
-```rust
-use hang::cmaf::*;
-use std::fs;
-
-// Read CMAF file
-let data = fs::read("video.mp4")?;
-
-// Convert to hang broadcast
-let broadcast = Broadcast::from_cmaf(&data)?;
-
-// Publish to relay
-connection.publish_broadcast(broadcast).await?;
-```
-
-### Custom QUIC Configuration
-
-Use custom QUIC settings:
-
-```rust
-use moq_lite::*;
-use quinn::*;
-
-// Create custom QUIC client config
-let mut client_config = ClientConfig::new(Arc::new(rustls_config));
-client_config.transport_config(Arc::new({
- let mut config = TransportConfig::default();
- config.max_concurrent_uni_streams(1000u32.into());
- config.max_concurrent_bidi_streams(100u32.into());
- config
-}));
-
-// Create connection with custom config
-let connection = Connection::connect_with_config(
- "https://relay.example.com/demo",
- client_config
-).await?;
-```
-
-## Testing Examples
-
-### Mock Relay for Testing
-
-Create a mock relay for unit tests:
-
-```rust
-use moq_lite::*;
-
-#[tokio::test]
-async fn test_publish_subscribe() {
- // Create in-memory relay
- let relay = MockRelay::new();
-
- // Connect publisher
- let pub_conn = relay.connect("test").await.unwrap();
- let mut broadcast = BroadcastProducer::new("test-broadcast");
- let mut track = broadcast.create_track("test-track");
-
- // Publish data
- let mut group = track.append_group();
- group.write(b"test data").unwrap();
- group.close().unwrap();
- pub_conn.publish(&mut broadcast).await.unwrap();
-
- // Connect subscriber
- let sub_conn = relay.connect("test").await.unwrap();
- let broadcast = sub_conn.consume("test-broadcast").await.unwrap();
- let mut track = broadcast.subscribe("test-track").await.unwrap();
-
- // Read data
- let group = track.next_group().await.unwrap().unwrap();
- let data = group.read().await.unwrap().unwrap();
-
- assert_eq!(data, b"test data");
-}
-```
-
-## More Examples
-
-For more examples, see the repository:
-
-- [Rust examples directory](https://github.com/moq-dev/moq/tree/main/rs)
-- [moq-lite examples](https://github.com/moq-dev/moq/tree/main/rs/moq-lite/examples)
-- [hang examples](https://github.com/moq-dev/moq/tree/main/rs/hang/examples)
-- [moq-native examples](https://github.com/moq-dev/moq/tree/main/rs/moq-native/examples)
-
-## Next Steps
-
-- Read the [moq-lite API](/rust/moq-lite)
-- Read the [hang API](/rust/hang)
-- View [API reference](/api/rust)
-- Check out [TypeScript examples](/typescript/examples)
diff --git a/doc/rust/hang.md b/doc/rust/hang.md
deleted file mode 100644
index 57e9091489..0000000000
--- a/doc/rust/hang.md
+++ /dev/null
@@ -1,290 +0,0 @@
----
-title: hang
-description: Media library built on moq-lite
----
-
-# hang
-
-[](https://crates.io/crates/hang)
-[](https://docs.rs/hang)
-[](https://github.com/moq-dev/moq/blob/main/LICENSE-MIT)
-
-A media library built on top of [moq-lite](/rust/moq-lite) for streaming audio and video.
-
-## Overview
-
-`hang` provides media-specific functionality on top of the generic `moq-lite` transport:
-
-- **Broadcast** - Discoverable collection of tracks with catalog
-- **Catalog** - Metadata describing available tracks, codec info, etc. (updated live)
-- **Track** - Audio/video streams and other data types
-- **Group** - Group of pictures (video) or collection of samples (audio)
-- **Frame** - Timestamp + codec payload pair
-
-## Installation
-
-Add to your `Cargo.toml`:
-
-```toml
-[dependencies]
-hang = "0.1"
-```
-
-## Supported Codecs
-
-`hang` implements most of the [WebCodecs specification](https://www.w3.org/TR/webcodecs/).
-
-**Video:**
-- H.264 (AVC)
-- H.265 (HEVC)
-- VP8
-- VP9
-- AV1
-
-**Audio:**
-- AAC
-- Opus
-
-## Quick Start
-
-### Publishing Video
-
-```rust
-use hang::*;
-
-#[tokio::main]
-async fn main() -> Result<(), Box> {
- // Connect to relay
- let connection = moq_lite::Connection::connect(
- "https://relay.example.com/demo"
- ).await?;
-
- // Create a hang broadcast
- let mut broadcast = Broadcast::new("my-stream");
-
- // Create video track
- let video_track = broadcast.create_video_track(VideoConfig {
- codec: "avc1.64002a".to_string(),
- width: 1920,
- height: 1080,
- framerate: 30.0,
- bitrate: 5_000_000,
- })?;
-
- // Create audio track
- let audio_track = broadcast.create_audio_track(AudioConfig {
- codec: "opus".to_string(),
- sample_rate: 48000,
- channels: 2,
- bitrate: 128_000,
- })?;
-
- // Publish encoded frames
- video_track.append_frame(Frame {
- timestamp: 0,
- data: h264_keyframe_data,
- is_keyframe: true,
- })?;
-
- audio_track.append_frame(Frame {
- timestamp: 0,
- data: opus_packet_data,
- is_keyframe: false,
- })?;
-
- // Publish to relay
- connection.publish_broadcast(broadcast).await?;
-
- Ok(())
-}
-```
-
-### Subscribing to Video
-
-```rust
-use hang::*;
-
-#[tokio::main]
-async fn main() -> Result<(), Box> {
- // Connect to relay
- let connection = moq_lite::Connection::connect(
- "https://relay.example.com/demo"
- ).await?;
-
- // Subscribe to broadcast
- let broadcast = connection.consume_broadcast("my-stream").await?;
-
- // Read catalog to discover tracks
- let catalog = broadcast.catalog().await?;
-
- for track_info in catalog.tracks {
- println!("Track: {} ({})", track_info.name, track_info.codec);
-
- if track_info.kind == "video" {
- // Subscribe to video track
- let track = broadcast.subscribe(&track_info.name).await?;
-
- // Read frames
- while let Some(frame) = track.next_frame().await? {
- println!("Video frame: {}µs, {} bytes",
- frame.timestamp, frame.data.len());
- // Decode with your video decoder
- }
- }
- }
-
- Ok(())
-}
-```
-
-## Catalog
-
-The catalog is a special track containing JSON metadata about available tracks:
-
-```json
-{
- "version": 1,
- "tracks": [
- {
- "name": "video",
- "kind": "video",
- "codec": "avc1.64002a",
- "width": 1920,
- "height": 1080,
- "framerate": 30,
- "bitrate": 5000000
- },
- {
- "name": "audio",
- "kind": "audio",
- "codec": "opus",
- "sampleRate": 48000,
- "channelConfig": "2",
- "bitrate": 128000
- }
- ]
-}
-```
-
-The catalog is updated live as tracks are added, removed, or changed.
-
-## Frame Container
-
-Each frame in `hang` consists of:
-
-```rust
-pub struct Frame {
- pub timestamp: u64, // Microseconds
- pub data: Vec, // Codec bitstream
- pub is_keyframe: bool, // Keyframe flag
-}
-```
-
-This simple container:
-- Works with WebCodecs
-- Minimal overhead
-- Codec-agnostic
-- Any timestamp base
-
-## CMAF Import
-
-`hang` includes a `cmaf` module to import fMP4/CMAF files:
-
-```rust
-use hang::cmaf::*;
-
-// Import CMAF file
-let cmaf_data = std::fs::read("video.mp4")?;
-let broadcast = Broadcast::from_cmaf(&cmaf_data)?;
-
-// Publish to relay
-connection.publish_broadcast(broadcast).await?;
-```
-
-This is useful for:
-- Ingesting existing content
-- Converting VOD to live
-- Testing with sample files
-
-Note: The CMAF importer is basic and doesn't support all features.
-
-## Grouping
-
-Groups are aligned with natural boundaries:
-
-**Video:**
-- Start with keyframe (I-frame)
-- Include dependent frames (P/B-frames)
-- Enable joining at group boundaries
-
-**Audio:**
-- Collection of audio packets
-- Usually 1 second of audio
-- Independent decoding
-
-```rust
-let mut group = track.new_group();
-group.append(keyframe)?;
-group.append(p_frame_1)?;
-group.append(p_frame_2)?;
-group.finalize()?;
-```
-
-## Prioritization
-
-`hang` automatically prioritizes:
-
-1. **Keyframes** - Highest priority (can't decode without them)
-2. **Recent frames** - Higher priority than old frames
-3. **Audio** - Often prioritized over video
-
-This is handled automatically based on frame metadata.
-
-## Examples
-
-The repository includes examples:
-
-- [Video track](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/video.rs) - Publishing video frames
-
-## CLI Tool
-
-The `hang-cli` package provides a command-line tool (binary name: `hang`):
-
-```bash
-# Install
-cargo install hang-cli
-
-# Publish a video file
-hang publish video.mp4
-
-# Publish from FFmpeg
-ffmpeg -i input.mp4 -f mpegts - | hang publish -
-
-# Custom encoding settings
-hang publish --codec h264 --bitrate 2000000 video.mp4
-```
-
-See `hang --help` for all options.
-
-## API Reference
-
-Full API documentation: [docs.rs/hang](https://docs.rs/hang)
-
-Key types:
-
-- `Broadcast` - Media broadcast with catalog
-- `Catalog` - Track metadata
-- `VideoConfig` / `AudioConfig` - Track configuration
-- `Frame` - Timestamp + codec bitstream
-- `cmaf` module - CMAF/fMP4 import
-
-## Protocol Specification
-
-See the [hang specification](https://moq-dev.github.io/drafts/draft-lcurley-moq-hang.html) for protocol details.
-
-## Next Steps
-
-- Use the [moq-lite](/rust/moq-lite) transport layer
-- Deploy a [relay server](/rust/moq-relay)
-- Read the [Architecture guide](/guide/architecture)
-- View [code examples](/rust/examples)
diff --git a/doc/rust/index.md b/doc/rust/index.md
deleted file mode 100644
index eae1372d9e..0000000000
--- a/doc/rust/index.md
+++ /dev/null
@@ -1,261 +0,0 @@
----
-title: Rust Libraries
-description: Rust implementation of MoQ protocol and tools
----
-
-# Rust Libraries
-
-The Rust implementation provides the reference implementation of the MoQ protocol, along with server-side tools and native applications.
-
-## Core Libraries
-
-### moq-lite
-
-[](https://crates.io/crates/moq-lite)
-[](https://docs.rs/moq-lite)
-
-The core pub/sub transport protocol implementing the [moq-lite specification](https://moq-dev.github.io/drafts/draft-lcurley-moq-lite.html).
-
-**Features:**
-- Broadcasts, tracks, groups, and frames
-- Built-in concurrency and deduplication
-- QUIC stream management
-- Prioritization and backpressure
-
-[Learn more →](/rust/moq-lite)
-
-### hang
-
-[](https://crates.io/crates/hang)
-[](https://docs.rs/hang)
-
-Media-specific encoding/streaming library built on top of `moq-lite`.
-
-**Features:**
-- Catalog for track discovery
-- Container format (timestamp + codec bitstream)
-- Support for H.264/265, VP8/9, AV1, AAC, Opus
-- CMAF/fMP4 import
-
-[Learn more →](/rust/hang)
-
-## Server Tools
-
-### moq-relay
-
-A stateless relay server that routes broadcasts between publishers and subscribers.
-
-**Features:**
-- Fan-out to multiple subscribers
-- Cross-region clustering
-- JWT-based authentication
-- HTTP debugging endpoints
-
-[Learn more →](/rust/moq-relay)
-
-### moq-token
-
-[](https://docs.rs/moq-token)
-
-JWT authentication library and CLI tool for generating tokens.
-
-**Features:**
-- HMAC and RSA/ECDSA signing
-- Path-based authorization
-- Token generation and verification
-- Available as library and CLI
-
-See [Authentication guide](/guide/authentication)
-
-## Utilities
-
-### moq-native
-
-[](https://docs.rs/moq-native)
-
-Opinionated helpers to configure a Quinn QUIC endpoint.
-
-**Features:**
-- TLS certificate management
-- QUIC transport configuration
-- Connection setup helpers
-
-### moq-clock
-
-Timing and clock utilities for synchronization.
-
-### libmoq
-
-[](https://docs.rs/libmoq)
-
-C bindings for `moq-lite` via FFI.
-
-**Use cases:**
-- Integrate with C/C++ applications
-- Bindings for other languages
-- Legacy system integration
-
-## CLI Tools
-
-### hang-cli
-
-Command-line tool for media operations (binary name: `hang`).
-
-**Features:**
-- Publish video from files or FFmpeg
-- Test and development
-- Media server deployments
-
-**Installation:**
-```bash
-cargo install hang-cli
-```
-
-**Usage:**
-```bash
-# Publish a video file
-hang publish video.mp4
-
-# Publish from FFmpeg
-ffmpeg -i input.mp4 -f mpegts - | hang publish -
-```
-
-### moq-token-cli
-
-Command-line tool for JWT token management (binary name: `moq-token`).
-
-**Installation:**
-```bash
-cargo install moq-token-cli
-```
-
-**Usage:**
-```bash
-# Generate a key
-moq-token --key root.jwk generate
-
-# Sign a token
-moq-token --key root.jwk sign \
- --root "rooms/123" \
- --publish "alice" \
- --expires 1735689600
-```
-
-See [Authentication guide](/guide/authentication) for details.
-
-## Installation
-
-### From crates.io
-
-Add to your `Cargo.toml`:
-
-```toml
-[dependencies]
-moq-lite = "0.1"
-hang = "0.1"
-```
-
-### From Source
-
-```bash
-git clone https://github.com/moq-dev/moq
-cd moq/rs
-cargo build --release
-```
-
-### Using Nix
-
-```bash
-# Build moq-relay
-nix build github:moq-dev/moq#moq-relay
-
-# Build hang-cli
-nix build github:moq-dev/moq#hang-cli
-```
-
-## Quick Start
-
-### Publishing (Rust)
-
-```rust
-use moq_lite::*;
-use tokio;
-
-#[tokio::main]
-async fn main() -> Result<(), Box> {
- // Connect to relay
- let connection = Connection::connect("https://relay.example.com/demo").await?;
-
- // Create a broadcast
- let mut broadcast = BroadcastProducer::new("my-broadcast");
-
- // Create a track
- let mut track = broadcast.create_track("chat");
-
- // Publish a group with a frame
- let mut group = track.append_group();
- group.write(b"Hello, MoQ!")?;
- group.close()?;
-
- // Publish to connection
- connection.publish(&mut broadcast).await?;
-
- Ok(())
-}
-```
-
-### Subscribing (Rust)
-
-```rust
-use moq_lite::*;
-use tokio;
-
-#[tokio::main]
-async fn main() -> Result<(), Box> {
- // Connect to relay
- let connection = Connection::connect("https://relay.example.com/demo").await?;
-
- // Subscribe to a broadcast
- let broadcast = connection.consume("my-broadcast").await?;
-
- // Subscribe to a track
- let mut track = broadcast.subscribe("chat").await?;
-
- // Read groups and frames
- while let Some(group) = track.next_group().await? {
- while let Some(frame) = group.read().await? {
- println!("Received: {:?}", frame);
- }
- }
-
- Ok(())
-}
-```
-
-## API Documentation
-
-Full API documentation is available on [docs.rs](https://docs.rs):
-
-- [moq-lite API](https://docs.rs/moq-lite)
-- [hang API](https://docs.rs/hang)
-- [moq-token API](https://docs.rs/moq-token)
-- [moq-native API](https://docs.rs/moq-native)
-- [libmoq API](https://docs.rs/libmoq)
-
-## Examples
-
-The repository includes several examples:
-
-- [Chat track](https://github.com/moq-dev/moq/blob/main/rs/moq-native/examples/chat.rs) - Publishing and subscribing to text
-- [Clock track](https://github.com/moq-dev/moq/tree/main/rs/moq-clock) - Timestamp synchronization
-- [Video track](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/video.rs) - Publishing video frames
-
-[View more examples →](/rust/examples)
-
-## Next Steps
-
-- Explore [moq-lite](/rust/moq-lite) - Core protocol
-- Explore [hang](/rust/hang) - Media library
-- Deploy [moq-relay](/rust/moq-relay) - Relay server
-- View [code examples](/rust/examples)
-- Read [API reference](/api/rust)
diff --git a/doc/rust/moq-lite.md b/doc/rust/moq-lite.md
deleted file mode 100644
index 91e2ff71cf..0000000000
--- a/doc/rust/moq-lite.md
+++ /dev/null
@@ -1,244 +0,0 @@
----
-title: moq-lite
-description: Core pub/sub transport protocol in Rust
----
-
-# moq-lite
-
-[](https://crates.io/crates/moq-lite)
-[](https://docs.rs/moq-lite)
-[](https://github.com/moq-dev/moq/blob/main/LICENSE-MIT)
-
-The core pub/sub transport protocol implementing the [moq-lite specification](https://moq-dev.github.io/drafts/draft-lcurley-moq-lite.html).
-
-## Overview
-
-`moq-lite` provides the networking layer for MoQ, implementing broadcasts, tracks, groups, and frames on top of QUIC. Live media is built on top of this layer using something like [hang](/rust/hang).
-
-## Core Concepts
-
-- **Broadcasts** - Discoverable collections of tracks
-- **Tracks** - Named streams of data, split into groups
-- **Groups** - Sequential collections of frames, independently decodable
-- **Frames** - Timed chunks of data
-
-## Installation
-
-Add to your `Cargo.toml`:
-
-```toml
-[dependencies]
-moq-lite = "0.1"
-```
-
-## Quick Start
-
-### Publishing
-
-```rust
-use moq_lite::*;
-
-#[tokio::main]
-async fn main() -> Result<(), Box> {
- // Connect to a relay
- let connection = Connection::connect("https://relay.example.com/demo").await?;
-
- // Create a broadcast
- let mut broadcast = BroadcastProducer::new("my-broadcast");
-
- // Create a track
- let mut track = broadcast.create_track("chat");
-
- // Append a group with frames
- let mut group = track.append_group();
- group.write(b"Hello, MoQ!")?;
- group.write(b"Second message")?;
- group.close()?;
-
- // Publish to the relay
- connection.publish(&mut broadcast).await?;
-
- Ok(())
-}
-```
-
-### Subscribing
-
-```rust
-use moq_lite::*;
-
-#[tokio::main]
-async fn main() -> Result<(), Box> {
- // Connect to a relay
- let connection = Connection::connect("https://relay.example.com/demo").await?;
-
- // Subscribe to a broadcast
- let broadcast = connection.consume("my-broadcast").await?;
-
- // Subscribe to a specific track
- let mut track = broadcast.subscribe("chat").await?;
-
- // Read groups and frames
- while let Some(group) = track.next_group().await? {
- println!("New group: {}", group.id());
-
- while let Some(frame) = group.read().await? {
- println!("Frame: {:?}", frame);
- }
- }
-
- Ok(())
-}
-```
-
-### Track Discovery
-
-```rust
-use moq_lite::*;
-
-#[tokio::main]
-async fn main() -> Result<(), Box> {
- let connection = Connection::connect("https://relay.example.com/demo").await?;
-
- // Wait for announcements
- while let Some(announcement) = connection.announced().await? {
- println!("New broadcast: {}", announcement.name);
-
- // Subscribe to the broadcast
- let broadcast = connection.consume(&announcement.name).await?;
-
- // Process tracks...
- }
-
- Ok(())
-}
-```
-
-## Features
-
-### Concurrency
-
-`moq-lite` is designed for concurrent use:
-
-- Multiple tracks can be published/subscribed simultaneously
-- Groups are delivered over independent QUIC streams
-- Built-in deduplication for shared subscriptions
-
-### Prioritization
-
-Groups can be prioritized:
-
-```rust
-let mut group = track.append_group();
-group.set_priority(10); // Higher priority
-group.write(keyframe_data)?;
-```
-
-This leverages QUIC's stream prioritization to send important data first.
-
-### Partial Reliability
-
-Old groups can be dropped when behind:
-
-```rust
-let mut group = track.append_group();
-group.set_expires(Duration::from_secs(2)); // Drop if not delivered in 2s
-```
-
-This maintains real-time latency by skipping stale data.
-
-## Connection Management
-
-### TLS Configuration
-
-```rust
-use moq_lite::*;
-
-let mut config = ConnectionConfig::default();
-config.set_verify_certificate(true);
-
-let connection = Connection::connect_with_config(
- "https://relay.example.com/demo",
- config
-).await?;
-```
-
-### Authentication
-
-Pass JWT tokens via query parameters:
-
-```rust
-let url = format!("https://relay.example.com/demo?jwt={}", token);
-let connection = Connection::connect(&url).await?;
-```
-
-See [Authentication guide](/guide/authentication) for details.
-
-## Error Handling
-
-```rust
-use moq_lite::*;
-
-match connection.publish(&mut broadcast).await {
- Ok(()) => println!("Published successfully"),
- Err(Error::ConnectionClosed) => println!("Connection closed"),
- Err(Error::InvalidPath(path)) => println!("Invalid path: {}", path),
- Err(e) => println!("Other error: {}", e),
-}
-```
-
-## Advanced Usage
-
-### Custom Transport
-
-Use your own QUIC implementation:
-
-```rust
-use moq_lite::*;
-use quinn::Connection as QuinnConnection;
-
-let quinn_conn = /* your Quinn connection */;
-let connection = Connection::from_quic(quinn_conn);
-```
-
-### Metadata
-
-Attach metadata to broadcasts and tracks:
-
-```rust
-let mut broadcast = BroadcastProducer::new("my-broadcast");
-broadcast.set_metadata("description", "My awesome broadcast");
-
-let mut track = broadcast.create_track("video");
-track.set_metadata("codec", "h264");
-```
-
-## Examples
-
-The repository includes several examples:
-
-- [Chat track](https://github.com/moq-dev/moq/blob/main/rs/moq-native/examples/chat.rs) - Text messaging
-- [Clock track](https://github.com/moq-dev/moq/tree/main/rs/moq-clock) - Timestamp synchronization
-
-## API Reference
-
-Full API documentation: [docs.rs/moq-lite](https://docs.rs/moq-lite)
-
-Key types:
-
-- `Connection` - Connection to a relay
-- `BroadcastProducer` / `BroadcastConsumer` - Publish/subscribe to broadcasts
-- `Track` - Named stream within a broadcast
-- `Group` - Collection of frames
-- `Frame` - Individual data chunk
-
-## Protocol Specification
-
-See the [moq-lite specification](https://moq-dev.github.io/drafts/draft-lcurley-moq-lite.html) for protocol details.
-
-## Next Steps
-
-- Build media apps with [hang](/rust/hang)
-- Deploy a [relay server](/rust/moq-relay)
-- Read the [Architecture guide](/guide/architecture)
-- View [code examples](/rust/examples)
diff --git a/doc/rust/moq-relay.md b/doc/rust/moq-relay.md
deleted file mode 100644
index ff9fbe8f9c..0000000000
--- a/doc/rust/moq-relay.md
+++ /dev/null
@@ -1,314 +0,0 @@
----
-title: moq-relay
-description: Clusterable relay server for MoQ
----
-
-# moq-relay
-
-A stateless relay server that routes broadcasts between publishers and subscribers, performing caching, deduplication, and fan-out.
-
-## Overview
-
-`moq-relay` is designed to run in datacenters, relaying media across multiple hops to improve quality of service and enable massive scale.
-
-**Features:**
-- Fan-out to multiple subscribers
-- Caching and deduplication
-- Cross-region clustering
-- JWT-based authentication
-- HTTP debugging endpoints
-
-## Installation
-
-### From Source
-
-```bash
-git clone https://github.com/moq-dev/moq
-cd moq
-cargo build --release --bin moq-relay
-```
-
-The binary will be in `target/release/moq-relay`.
-
-### Using Cargo
-
-```bash
-cargo install moq-relay
-```
-
-### Using Nix
-
-```bash
-nix build github:moq-dev/moq#moq-relay
-```
-
-## Configuration
-
-Create a `relay.toml` configuration file:
-
-```toml
-[server]
-bind = "[::]:4443" # Listen on all interfaces, port 4443
-
-[tls]
-cert = "/path/to/cert.pem" # TLS certificate
-key = "/path/to/key.pem" # TLS private key
-
-[auth]
-public = "anon" # Allow anonymous access to anon/**
-key = "root.jwk" # JWT key for authenticated paths
-```
-
-See [dev.toml](https://github.com/moq-dev/moq/blob/main/rs/moq-relay/cfg/dev.toml) for a complete example.
-
-## Running
-
-```bash
-moq-relay --config relay.toml
-```
-
-Or with the config path as the only argument:
-
-```bash
-moq-relay relay.toml
-```
-
-## HTTP Endpoints
-
-For debugging, the relay exposes HTTP endpoints on the same bind address (TCP instead of UDP):
-
-### GET /certificate.sha256
-
-Returns the fingerprint of the TLS certificate:
-
-```bash
-curl http://localhost:4443/certificate.sha256
-```
-
-### GET /announced/*prefix
-
-Returns all announced tracks with the given prefix:
-
-```bash
-# All announced broadcasts
-curl http://localhost:4443/announced/
-
-# Broadcasts under "demo/"
-curl http://localhost:4443/announced/demo
-```
-
-### GET /fetch/*path
-
-Returns the latest group of the given track:
-
-```bash
-curl http://localhost:4443/fetch/demo/video
-```
-
-::: warning
-The HTTP server listens on TCP, not HTTPS. It's intended for local debugging only.
-:::
-
-## Clustering
-
-Multiple relay instances can cluster for geographic distribution:
-
-```toml
-[cluster]
-root = "https://root-relay.example.com" # Root node
-node = "https://us-east.relay.example.com" # This node's address
-```
-
-### How Clustering Works
-
-`moq-relay` uses a simple clustering scheme:
-
-1. **Root node** - A single relay (can serve public traffic) that tracks cluster membership
-2. **Other nodes** - Accept internet traffic and consult the root for routing
-
-When a relay publishes a broadcast, it advertises its `node` address to other relays via the root.
-
-### Cluster Arguments
-
-- `--cluster-root ` - Hostname/IP of the root node (omit to make this node the root)
-- `--cluster-node ` - Hostname/IP of this instance (needs valid TLS cert)
-
-### Benefits
-
-- Lower latency (users connect to nearest relay)
-- Higher availability (redundancy)
-- Geographic distribution
-
-### Current Limitations
-
-- Mesh topology (all relays connect to all others)
-- Not optimized for large clusters (3-5 nodes recommended)
-- Single root node (future: multi-root)
-
-## Authentication
-
-The relay supports JWT-based authentication. See the [Authentication guide](/guide/authentication) for detailed setup.
-
-### Quick Setup
-
-1. Generate a key:
-
-```bash
-moq-token --key root.jwk generate
-```
-
-2. Configure relay:
-
-```toml
-[auth]
-key = "root.jwk"
-public = "anon" # Optional: allow anonymous access to anon/**
-```
-
-3. Generate tokens:
-
-```bash
-moq-token --key root.jwk sign \
- --root "rooms/123" \
- --publish "alice" \
- --subscribe "" \
- --expires 1735689600 > alice.jwt
-```
-
-4. Connect with token:
-
-```
-https://relay.example.com/rooms/123?jwt=
-```
-
-### Token Claims
-
-- `root` - Root path for all operations
-- `pub` - Publishing permissions (path suffix)
-- `sub` - Subscription permissions (path suffix)
-- `cluster` - Cluster node flag
-- `exp` - Expiration (unix timestamp)
-
-### Anonymous Access
-
-To allow anonymous access to a path prefix:
-
-```toml
-[auth]
-public = "anon" # Allow access to anon/** without token
-key = "root.jwk" # Require token for other paths
-```
-
-To make everything public (not recommended):
-
-```toml
-[auth]
-public = "" # Allow access to all paths
-```
-
-## TLS Setup
-
-The relay requires TLS certificates. Use [Let's Encrypt](https://letsencrypt.org/):
-
-```bash
-# Install certbot
-sudo apt install certbot # Ubuntu/Debian
-brew install certbot # macOS
-
-# Generate certificate
-sudo certbot certonly --standalone -d relay.example.com
-```
-
-Update `relay.toml`:
-
-```toml
-[tls]
-cert = "/etc/letsencrypt/live/relay.example.com/fullchain.pem"
-key = "/etc/letsencrypt/live/relay.example.com/privkey.pem"
-```
-
-## Production Deployment
-
-See the [Deployment guide](/guide/deployment) for:
-
-- Running as a systemd service
-- Cloud deployment (Linode, AWS, GCP, etc.)
-- Multi-region clustering
-- Monitoring and logging
-- Performance tuning
-
-## Monitoring
-
-### Logging
-
-Set log level via environment variable:
-
-```bash
-RUST_LOG=info moq-relay relay.toml
-RUST_LOG=debug moq-relay relay.toml
-RUST_LOG=moq_relay=trace moq-relay relay.toml
-```
-
-### Metrics
-
-Metrics (Prometheus format) are planned but not yet implemented.
-
-Current visibility:
-- Check logs for connection count
-- Use HTTP endpoints for track inspection
-- Monitor system resources (CPU, memory, bandwidth)
-
-## Performance
-
-### Current Status
-
-- **Single-threaded** - Quinn uses one UDP receive thread
-- **In-memory caching** - Recent groups stored in RAM
-- **Mesh clustering** - All relays connect to all others
-
-### Scaling
-
-- **Vertical** - Fast CPU matters more than core count
-- **Horizontal** - Deploy multiple relays in different regions
-- **Cluster size** - 3-5 nodes optimal with current implementation
-
-### Future Improvements
-
-- Multi-threaded UDP processing
-- Tree-based clustering topology
-- Improved memory management
-- Metrics and observability
-
-## Troubleshooting
-
-### Port Already in Use
-
-```bash
-# Check what's using port 4443
-lsof -i :4443
-
-# Kill the process or use a different port
-```
-
-### Certificate Errors
-
-Ensure:
-- Certificate is valid and not expired
-- Certificate matches domain name
-- Private key has correct permissions
-- Certificate includes full chain
-
-### Connection Timeouts
-
-Check:
-- UDP port is open in firewall
-- Cloud provider allows UDP traffic
-- TLS certificate is valid
-- Relay is actually running
-
-## Next Steps
-
-- Set up [Authentication](/guide/authentication)
-- Deploy to production ([Deployment guide](/guide/deployment))
-- Use [moq-lite](/rust/moq-lite) client library
-- Build media apps with [hang](/rust/hang)
diff --git a/doc/setup/demo/boy.md b/doc/setup/demo/boy.md
new file mode 100644
index 0000000000..beeb438f99
--- /dev/null
+++ b/doc/setup/demo/boy.md
@@ -0,0 +1,129 @@
+---
+title: MoQ Boy
+description: Crowd-controlled Game Boy Color streaming via MoQ.
+---
+
+# MoQ Boy
+
+A "Twitch Plays" style demo where Game Boy Color games run server-side and stream live video + audio to web viewers. Anyone can send inputs — all inputs are applied immediately (anarchy mode). Multiple emulator instances can run simultaneously with different ROMs.
+
+This demo showcases MoQ's key differentiators:
+
+- **Prefix-based discovery** — game sessions are discovered automatically via announcement prefixes
+- **Bidirectional communication** — viewers send button inputs back to the emulator
+- **Low-latency video + audio** — H.264 video and Opus audio at native Game Boy resolution and framerate
+- **Multi-viewer interaction** — all viewers see which buttons are being pressed in real-time
+
+## Running
+
+```bash
+just demo boy
+```
+
+This starts three components in parallel:
+
+1. **Relay** — a localhost MoQ relay server
+2. **Emulator publisher** — a Rust binary running a Game Boy Color emulator, encoding video + audio
+3. **Web viewer** — a Vite dev server with a browser UI
+
+The default ROM ([Big2Small](https://github.com/mdsteele/big2small), a GPLv3 puzzle game) is downloaded automatically on first run.
+
+### Custom ROM
+
+```bash
+just demo boy start path/to/game.gb
+```
+
+### Multiple Sessions
+
+Run additional instances in separate terminals with different ROMs:
+
+```bash
+just demo boy start path/to/other.gb
+```
+
+Each session appears in the grid automatically via MoQ's announcement system.
+
+## Controls
+
+Click a session card to expand it, then:
+
+- **Arrow keys** — D-pad
+- **Z** — A button
+- **X** — B button
+- **Enter** — Start
+- **Shift** — Select
+- **Escape** — collapse the card
+
+On-screen buttons are also available. All buttons highlight when pressed (by you or anyone else).
+
+### Reset
+
+- **Reset button** — any viewer can reset the game
+- **Auto-reset** — the game resets after 5 minutes of inactivity, with a countdown warning
+
+## Architecture
+
+### Path Prefixes
+
+The emulator and viewer use configurable path prefixes to separate authenticated and anonymous traffic:
+
+| Environment | Game Prefix | Viewer Prefix |
+|-------------|-------------|---------------|
+| **Localhost** | `anon/boy/game` | `anon/boy/viewer` |
+| **Production** | `demo/boy/game` (authenticated) | `anon/boy/viewer` (unauthenticated) |
+
+### Broadcast Hierarchy
+
+```text
+{gamePrefix}/
+ {name}/ <- game session broadcast
+ catalog.json <- video + audio renditions (managed by moq-mux)
+ video0.avc3 <- 160x144 H.264 video at ~60fps
+ audio0.opus <- Opus audio
+ status <- JSON state (raw moq-lite track)
+
+{viewerPrefix}/
+ {name}/
+ {viewerId}/ <- viewer broadcast
+ command <- JSON commands (raw moq-lite track)
+```
+
+### Discovery
+
+- **Viewers discover sessions** — subscribe to announcements with the game prefix, filter to single-component suffixes
+- **Emulator discovers viewers** — subscribes to the viewer prefix using `OriginProducer::with_root()`
+
+### Auto-Pause
+
+Emulation and encoding are automatically paused when no viewers are watching. When a viewer connects, emulation resumes immediately with a fresh keyframe.
+
+### Video Pipeline
+
+The Rust publisher runs a Game Boy Color emulator (boytacean), grabs the framebuffer each frame, and encodes a single rendition:
+
+| Track | Codec | Resolution | Framerate | Method |
+|-------|-------|-----------|-----------|--------|
+| Video | H.264 (avc3) | 160x144 | ~60fps | RGBA -> YUV -> encode via ffmpeg-next |
+
+The web viewer upscales with CSS `image-rendering: pixelated` for crisp pixel art.
+
+### Audio Pipeline
+
+The emulator's APU outputs PCM audio samples which are encoded to Opus via ffmpeg-next and published through `moq_mux::import::Opus`.
+
+### Metadata Tracks
+
+| Track | Format | Content |
+|-------|--------|---------|
+| `status` | Raw JSON | `{"buttons": ["up", "a"], "latency": {"abc123": 42}}` |
+| `command` | Raw JSON | `{"type": "buttons", "buttons": ["left"]}` or `{"type": "reset"}` |
+
+These tracks bypass the hang container format — they're raw UTF-8 JSON bytes written directly to `moq_lite` groups.
+
+## Source Code
+
+- **Rust publisher**: [`rs/moq-boy/src/`](https://github.com/moq-dev/moq/tree/main/rs/moq-boy/src/) — `main.rs`, `emulator.rs`, `video.rs`, `audio.rs`, `input.rs`
+- **Web viewer**: [`js/moq-boy/src/`](https://github.com/moq-dev/moq/tree/main/js/moq-boy/src/) — `index.ts` (Game class), `element.ts` (web component), `ui/` (SolidJS components)
+- **Demo app**: [`demo/boy/`](https://github.com/moq-dev/moq/tree/main/demo/boy/) — HTML page with `` element
+- **Justfile**: [`demo/boy/justfile`](https://github.com/moq-dev/moq/tree/main/demo/boy/justfile)
diff --git a/doc/setup/demo/web.md b/doc/setup/demo/web.md
new file mode 100644
index 0000000000..0670f33fc4
--- /dev/null
+++ b/doc/setup/demo/web.md
@@ -0,0 +1,40 @@
+---
+title: Web Demo
+description: Browser-based watch and publish demo for MoQ.
+---
+
+# Web Demo
+
+A browser-based demo for watching and publishing live streams via MoQ. Uses the `` and `` web components.
+
+## Running
+
+```bash
+just web
+```
+
+This starts three components in parallel:
+
+1. **Relay** — a localhost MoQ relay server
+2. **Publisher** — `moq-cli` publishing Big Buck Bunny via ffmpeg
+3. **Web server** — a Vite dev server with the demo UI
+
+Once running, open the browser to watch the stream, publish from your camera, or both.
+
+## What It Shows
+
+- **Watching** — subscribe to live broadcasts with low latency
+- **Publishing** — capture camera, microphone, or screen and publish via MoQ
+- **Web Components** — `` and `` custom elements
+- **Discovery** — auto-discover available broadcasts via announcements
+
+## Source Code
+
+- **Web app**: [`demo/web/src/`](https://github.com/moq-dev/moq/tree/main/demo/web/src/)
+- **Justfile**: [`demo/web/justfile`](https://github.com/moq-dev/moq/tree/main/demo/web/justfile)
+
+## Related
+
+- [@moq/watch](/lib/js/@moq/watch) — Watch/subscribe package
+- [@moq/publish](/lib/js/@moq/publish) — Publish package
+- [Relay setup](/bin/relay/) — Server configuration
diff --git a/doc/setup/dev.md b/doc/setup/dev.md
new file mode 100644
index 0000000000..ecc4d1b748
--- /dev/null
+++ b/doc/setup/dev.md
@@ -0,0 +1,126 @@
+---
+title: Development Guide
+description: Set up the rest of the stuff.
+---
+
+# Development
+
+Still here? You must be a Big Buck Bunny fan.
+
+This guide covers the rest of the stuff you can run locally.
+
+## Just
+
+We use [Just](https://github.com/casey/just) to run helper commands.
+It's *just* a fancier `Makefile` so you don't have to remember all the commands.
+
+### Common Commands
+
+```bash
+# Run the demo (default)
+just
+
+# List all available commands
+just --list
+
+# This is equivalent to 3 terminal tabs:
+# just relay
+# just web
+# just pub bbb
+
+# Make sure the code compiles and passes linting
+just check
+
+# Auto-fix linting errors
+just fix
+
+# Run the tests
+just test
+
+# Publish a HLS broadcast (CMAF) over MoQ
+just pub-hls tos
+```
+
+Want more? See the [justfile](https://github.com/moq-dev/moq/blob/main/justfile) for all commands.
+
+### The Internet
+
+Most of the commands default to `http://localhost:4443/anon`.
+That's pretty lame.
+
+If you want to do a real test of how MoQ works over the internet, you're going to need a remote server.
+Fortunately I'm hosting a small cluster on Linode for just the occasion: `https://cdn.moq.dev`
+
+::: warning
+All of these commands are unauthenticated, hence the `/anon`.
+Anything you publish is public and discoverable... so be careful and don't abuse it.
+[Setup your own relay](/setup/prod) or contact `@kixelated` for an auth token.
+:::
+
+```bash
+# Run the web server, pointing to the public relay
+# NOTE: The `bbb` demo on moq.dev uses a different path so it won't show up.
+just web https://cdn.moq.dev/anon
+
+# Publish Tears of Steel, watch it via https://moq.dev/watch?name=tos
+just pub tos https://cdn.moq.dev/anon
+
+# Publish a clock broadcast
+just clock publish https://cdn.moq.dev/anon
+
+# Subscribe to said clock broadcast (different tab)
+just clock subscribe https://cdn.moq.dev/anon
+
+# Publish an authentication broadcast
+just pub av1 https://cdn.moq.dev/?jwt=not_a_real_token_ask_for_one
+```
+
+## Debugging
+
+### Rust
+
+You can set the logging level with the `RUST_LOG` environment variable.
+
+```bash
+# Print the most verbose logs
+RUST_LOG=trace just
+```
+
+If you're getting a panic, use `RUST_BACKTRACE=1` to get a backtrace.
+
+```bash
+# Print a backtrace on panic.
+RUST_BACKTRACE=1 just
+```
+
+## IDE Setup
+
+I use [Cursor](https://www.cursor.com/), but anything works.
+
+Recommended extensions:
+
+- [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
+- [Biome](https://marketplace.visualstudio.com/items?itemName=biomejs.biome)
+- [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig)
+- [direnv](https://marketplace.visualstudio.com/items?itemName=mkhl.direnv)
+
+## Contributing
+
+Run `just fix` before pushing your changes, otherwise CI will yell at you.
+It runs `just check` so that's the easiest way to debug any issues.
+
+Please don't submit a vibe coded PR unless you understand it.
+`You're absolutely right!` is not always good enough.
+
+## Onwards
+
+`just` runs three processes that normally, should run on separate hosts.
+Learn how to run them [in production](/setup/prod).
+
+Or take a detour and:
+
+- Brush up on the [concepts](/concept/).
+- Discover the other [apps](/bin/).
+- `use` the [Rust crates](/lib/rs/).
+- `import` the [Typescript packages](/lib/js/).
+- or IDK, go take a shower or something while Claude parses the docs.
diff --git a/doc/setup/development.md b/doc/setup/development.md
deleted file mode 100644
index a0b80cac57..0000000000
--- a/doc/setup/development.md
+++ /dev/null
@@ -1,97 +0,0 @@
----
-title: Development Setup
-description: Setting up your development environment
----
-
-# Development Setup
-
-This guide covers setting up a rad development environment.
-All of this is optional but is my recommended setup.
-
-And of course, check out the [Quick Start](/setup) first.
-
-## Development Commands
-
-I use [Just](https://github.com/casey/just) to run helper commands.
-It's just a fancier `Makefile` so you don't have to remember all the commands.
-
-### Common Commands
-```bash
-# List all available commands
-just
-
-# Run the demo
-just dev
-
-# This is equivalent to 3 terminal tabs:
-# just relay
-# just web
-# just pub bbb
-
-# Make sure the code compiles and passes linting
-just check
-
-# Auto-fix linting errors
-just fix
-
-# Run the tests
-just test
-```
-
-All of the commands default to `http://localhost:4443/anon`.
-You can target a different host by changing the first argument:
-
-```bash
-# WARNING: All of these commands use a public relay.
-# Anything you publish is publicly visible and accessible.
-# Contact @kixelated if you want an authenticated endpoint for sensitive content.
-
-# Run the web server, pointing to the public relay
-just web https://cdn.moq.dev/anon
-
-# Publish Tears of Steel, watch it via https://moq.dev/watch?name=tos
-just pub tos https://cdn.moq.dev/anon
-
-# Publish a clock broadcast
-just clock publish https://cdn.moq.dev/anon
-
-# Subscribe to a clock broadcast
-just clock subscribe https://cdn.moq.dev/anon
-```
-
-## Debugging
-
-### Rust
-You can set the logging level with the `RUST_LOG` environment variable.
-
-```bash
-# Print the most verbose logs
-RUST_LOG=trace just dev
-```
-
-If you're getting a panic, use `RUST_BACKTRACE=1` to get a backtrace.
-
-```bash
-# Print a backtrace on panic.
-RUST_BACKTRACE=1 just dev
-```
-
-
-## IDE Setup
-
-I use [Cursor](https://www.cursor.com/) and [VSCode](https://code.visualstudio.com/), but anything works.
-
-Recommended extensions:
-
-- [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
-- [Biome](https://marketplace.visualstudio.com/items?itemName=biomejs.biome)
-- [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig)
-- [direnv](https://marketplace.visualstudio.com/items?itemName=mkhl.direnv)
-
-
-## Contributing
-
-Just make sure to run `just fix` before pushing your changes, otherwise CI will yell at you.
-
-## What's Next?
-[P R O D U C T I O N](/setup/production)
diff --git a/doc/setup/index.md b/doc/setup/index.md
index 2703efcd30..785555e3ad 100644
--- a/doc/setup/index.md
+++ b/doc/setup/index.md
@@ -5,40 +5,46 @@ description: Get started with MoQ in seconds
# Quick Start
-We've got a few demos to show off some basic MoQ goodness.
-Everything runs on localhost [in development](/setup/development), but [in production](/setup/production) you'll want to split the components across multiple hosts.
+We've got a few demos to show off MoQ in action.
+Everything runs on localhost in development, but in production of course you'll run these components across multiple hosts.
+
+Start by cloning the repo:
+
+```bash
+git clone https://github.com/moq-dev/moq
+cd moq
+```
+
+Then pick your poison: Nix or not.
## Option 1: Using Nix (Recommended)
The recommended approach is to use [Nix](https://nixos.org/download.html).
-
-Give it a try!
-All dependencies are automatically downloaded, pinned to specific versions, and identical on CI and production.
+It's like Docker but without the VM; all dependencies are pinned to specific versions.
Install the following:
+
- [Nix](https://nixos.org/download.html)
- [Nix Flakes](https://nixos.wiki/wiki/Flakes)
+- (optional) [Nix Direnv](https://github.com/nix-community/nix-direnv)
Then run the demo:
+
```bash
-# Runs the demo
-nix develop -c just dev
+# Runs the demo using pinned dependencies
+nix develop -c just
```
-Want to make it easier? Install [nix-direnv](https://github.com/nix-community/nix-direnv), then you can simply run:
+If you install `direnv`, then the Nix shell will be loaded whenever you `cd` into the repo:
```bash
-# Once only: automatically uses nix-shell inside the repository.
-direnv allow
-
-# Runs the demo
-just dev
+# Run the demo... in 9 keystrokes
+just
```
-
## Option 2: Manual Installation
-If you prefer not to use Nix (or are a Windows fiend), then you can manually install the dependencies:
+If you don't like Nix or enjoy suffering with Windows, then you can manually install the dependencies:
- [Just](https://github.com/casey/just)
- [Rust](https://www.rust-lang.org/tools/install)
@@ -46,37 +52,46 @@ If you prefer not to use Nix (or are a Windows fiend), then you can manually ins
- [FFmpeg](https://ffmpeg.org/download.html)
- ...more?
+Some workspace crates have additional system dependencies and are excluded from the default build:
+
+- **moq-gst** — requires [GStreamer](https://gstreamer.freedesktop.org/) development libraries
+- **libmoq** — requires a C toolchain
+- **moq-ffi** — requires Python and [maturin](https://www.maturin.rs/)
+
+These are all included in the Nix dev shell. To build them manually, install the deps and use `cargo build -p `.
+
Then run:
+
```bash
# Install additional dependencies, usually linters
just install
# Run the demo
-just dev
+just
```
When in doubt, check the [Nix Flake](https://github.com/moq-dev/moq/blob/main/flake.nix) for the full list of dependencies.
## What's Happening?
-The `just dev` command starts three components:
+The `just` command starts three components:
-1. `moq-relay`: A server that routes live data between publishers and subscribers.
-2. `hang-cli`: A tool that publishes video content, in this case the classic "Big Buck Bunny".
-3. `hang-demo`: A web page with various demos, including a video player.
+- [moq-relay](/bin/relay/): A server that routes live data between publishers and subscribers.
+- [moq-cli](/bin/cli): A CLI that publishes video content piped from `ffmpeg`.
+- [demo](/lib/js/@moq/demo): A web page with various demos.
-Once everything compiles, it should open [https://localhost:5173](localhost:5173) in your browser.
-See the [development setup](/setup/development) guide for more cool things you can do.
+Once everything compiles, it should open [localhost:5173](http://localhost:5173) in your browser.
::: warning
The demo uses an insecure HTTP fetch for local development only. In production, you'll need a proper domain and TLS certificate via [LetsEncrypt](https://letsencrypt.org/docs/) or similar.
:::
-## What's Next?
-If you want to run this stuff in production, you should have separate hosts for the three components.
+### More Demos
+
+- [Web Demo](/setup/demo/web) — watch and publish live streams from a browser
+- [MoQ Boy](/setup/demo/boy) — crowd-controlled Game Boy Color streaming with live video, audio, and anarchy-mode input
-1. `moq-relay` can be run on any host(s) with a public IP address and an open UDP port.
-2. `hang-cli` can be run by any publisher client.
-3. `hang-demo` can be hosted on any web server or cloud provider.
+Check out the full [development guide](/setup/dev) for more commands, or try publishing to the public relay:
-Check out the full guide for [deploying to production](/setup/production).
+- [OBS](/bin/obs)
+- [GStreamer](/bin/gstreamer)
diff --git a/doc/setup/linux.md b/doc/setup/linux.md
new file mode 100644
index 0000000000..8533482c23
--- /dev/null
+++ b/doc/setup/linux.md
@@ -0,0 +1,111 @@
+---
+title: Linux Installation
+description: Install moq-relay, moq-cli, and the moq-gst plugin on Linux
+---
+
+# Linux Installation
+
+MoQ ships native Linux packages for Debian/Ubuntu and Fedora/RHEL/openSUSE.
+The relay, the CLI, the token utility, and the GStreamer plugin all install
+via your system package manager and stay current through normal
+`apt upgrade` / `dnf upgrade`.
+
+## Debian and Ubuntu
+
+Tested on Debian 12 (bookworm), Debian 13 (trixie), Ubuntu 22.04, Ubuntu 24.04.
+The `gstreamer1.0-moq` plugin needs GStreamer >= 1.22 and is only available
+on Debian 12+ / Ubuntu 24.04+; the other packages install on Ubuntu 22.04 too.
+
+```bash
+# Trust the project's signing key.
+curl -fsSL https://apt.moq.dev/moq-archive-keyring.gpg \
+ | sudo tee /usr/share/keyrings/moq-archive-keyring.gpg > /dev/null
+
+# Add the repository.
+echo "deb [signed-by=/usr/share/keyrings/moq-archive-keyring.gpg] https://apt.moq.dev stable main" \
+ | sudo tee /etc/apt/sources.list.d/moq.list
+
+sudo apt update
+
+# Pick what you need.
+sudo apt install moq-relay # relay server with systemd unit
+sudo apt install moq-cli # publish/subscribe CLI
+sudo apt install moq-token-cli # JWT token utility for moq-relay
+sudo apt install gstreamer1.0-moq # GStreamer plugin (moqsink, moqsrc)
+```
+
+## Fedora, RHEL, Rocky, AlmaLinux, openSUSE
+
+Tested on Fedora 39+, RHEL 9, Rocky 9, AlmaLinux 9.
+
+```bash
+sudo dnf config-manager --add-repo https://rpm.moq.dev/moq.repo
+sudo dnf install moq-relay # relay server with systemd unit
+sudo dnf install moq-cli # publish/subscribe CLI
+sudo dnf install moq-token-cli # JWT token utility
+sudo dnf install gstreamer1-moq # GStreamer plugin
+```
+
+On openSUSE use `zypper addrepo` instead of `dnf config-manager`.
+
+## Running moq-relay
+
+The package drops a systemd unit and a default config at
+`/etc/moq-relay/relay.toml`. Place your TLS cert, key, and JWK
+under `/var/lib/moq-relay/` (the service's `StateDirectory`), edit the
+config to taste, and enable the service:
+
+```bash
+sudo install -d -m 0750 /var/lib/moq-relay
+sudo cp cert.pem key.pem root.jwk /var/lib/moq-relay/
+sudo systemctl enable --now moq-relay
+sudo journalctl -u moq-relay -f
+```
+
+The service runs as a `DynamicUser` with `CAP_NET_BIND_SERVICE`, so port
+443 works without root. Your edits to `/etc/moq-relay/relay.toml` survive
+package upgrades.
+
+## Other Linux distributions
+
+If your distro doesn't have a native package on offer:
+
+- **Alpine, NixOS, air-gapped systems**: download the static binary from the
+ [GitHub Releases](https://github.com/moq-dev/moq/releases) page. Each
+ release attaches `moq-relay-v-x86_64-unknown-linux-gnu` and
+ `aarch64-unknown-linux-gnu` variants.
+- **Docker**: `docker pull docker.io/moqdev/moq-relay:latest`. Multi-arch
+ images for `linux/amd64` and `linux/arm64`.
+- **Nix**: the project ships a flake. The Cachix cache at `kixelated.cachix.org`
+ serves pre-built binaries, but only tagged releases are pushed, so pin the
+ ref to a recent tag and accept the flake's cache config to skip building from
+ source:
+
+ ```bash
+ nix run github:moq-dev/moq/moq-relay-v0.12.4#moq-relay --accept-flake-config
+ ```
+
+ An unpinned `github:moq-dev/moq#moq-relay` tracks the default branch, which
+ is not cached and compiles from source. To trust the cache permanently
+ instead of per-command, run `cachix use kixelated` once.
+- **Arch Linux**: a community-maintained PKGBUILD lives in the AUR
+ (`moq-relay-bin`). The project doesn't maintain it directly; treat it as
+ community supported.
+- **From source**: any system with a Rust toolchain can build via
+ `cargo install moq-relay`. The relay has no external runtime dependencies
+ beyond glibc.
+
+## Verifying signatures
+
+The apt and rpm repositories are signed with the same project GPG key. The
+public key is served at:
+
+-
+-
+
+Verify the apt repository metadata signature manually:
+
+```bash
+gpg --import moq-archive-keyring.gpg
+gpg --verify Release.gpg Release
+```
diff --git a/doc/setup/prod.md b/doc/setup/prod.md
new file mode 100644
index 0000000000..bba47e631e
--- /dev/null
+++ b/doc/setup/prod.md
@@ -0,0 +1,54 @@
+---
+title: Production Deployment
+description: Deploying moq-relay to production
+---
+
+# Production Deployment
+
+Here's a guide on how to get moq-relay running in production.
+
+## Overview
+
+[moq-relay](/bin/relay/) is the core of the MoQ stack.
+It's responsible for routing live tracks (payload agnostic) from 1 client to N clients.
+The relay accepts WebTransport connections from clients, but it can also connect to other relays to fetch upstream.
+Think of the relay as a HTTP web server like [Nginx](https://nginx.org/), but for live content.
+
+There are multiple companies working on MoQ CDNs (like [Cloudflare](https://moq.dev/blog/first-cdn)) so eventually it won't be necessary to self-host.
+However, you do unlock some powerful features by self-hosting, such as running relays within your internal network.
+
+## QUIC Requirements
+
+Before we get carried away, we need to cover the QUIC requirements:
+
+1. QUIC is a client-server protocol, so you **MUST** have a server with a static IP address.
+2. QUIC requires TLS, so you **MUST** have a TLS certificate, even if it's self-signed.
+3. QUIC uses UDP, so you **MUST** configure your firewall to allow UDP traffic.
+4. QUIC load balancers don't exist yet, so you **MUST** design your own load balancer.
+
+These make it a bit more difficult to deploy, but don't worry we have you covered.
+
+## Self-Hosting
+
+MoQ should work just fine inside your own network or infrastructure provided you understand the QUIC requirements.
+
+You need at least one server with some way to discover its IP address.
+DNS is the easiest way to do this, but some other way of getting an IP address should also work.
+QUIC also has really awesome anycast support but that's a bit more advanced; reach out if you're interested.
+
+TLS is where most people get stuck.
+[See my blog post](https://moq.dev/blog/tls-and-quic) for more details, but here's the important bits:
+
+- QUIC uses the same TLS certificate as HTTPS.
+- However, TLS load balancers currently don't support QUIC, so you need to provision your own TLS certificates.
+- You can disable TLS verification if you don't care about MITM attacks, but only for native clients.
+- Web browsers can support self-signed certificates via [fingerprint verification](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport/WebTransport#servercertificatehashes), but it's limited to ephemeral certificates (<2 weeks).
+
+And of course, make sure UDP is allowed on your firewall.
+The default WebTransport port is UDP/443 but anything will work if you put it in the URL.
+
+## Next Steps
+
+- Set up [Authentication](/bin/relay/auth)
+- Configure [Clustering](/bin/relay/cluster)
+- Learn about [Concepts](/concept/)
diff --git a/doc/setup/production.md b/doc/setup/production.md
deleted file mode 100644
index 8c69418fcd..0000000000
--- a/doc/setup/production.md
+++ /dev/null
@@ -1,109 +0,0 @@
----
-title: Production Setup
-description: Setting up your production environment
----
-
-# Production Setup
-
-Here's a quick guide on how to get MoQ running in production.
-
-## Relay
-[moq-relay](/rust/moq-relay) is the core of the MoQ stack.
-It's responsible for routing live tracks (payload agnostic) from 1 client to N clients.
-The relay accepts WebTransport connections from clients, but it can also connect to other relays to fetch upstream.
-Think of the relay as a HTTP web server like [Nginx](https://nginx.org/), but for live content.
-
-There are multiple companies working on MoQ CDNs (like [Cloudflare](https://moq.dev/blog/first-cdn)) so eventually it won't be necessary to self-host.
-However, you do unlock some powerful features by self-hosting, such as running relays within your internal network.
-
-### QUIC Requirements
-Before we get carried away, we need to cover the [QUIC](/guide/quic) requirements:
-
-1. QUIC is a client-server protocol, so you **MUST** have a server with a static IP address.
-2. QUIC requires TLS, so you **MUST** have a TLS certificate, even if it's self-signed.
-3. QUIC uses UDP, so you **MUST** configure your firewall to allow UDP traffic.
-4. QUIC load balancers don't exist yet, so you **MUST** design your own load balancer.
-
-These make it a bit more difficult to deploy, but don't worry we have you covered.
-
-### EZ Mode
-`https://cdn.moq.dev` is a free, public MoQ relay.
-Check out the [cdn](https://github.com/moq-dev/moq/tree/main/cdn) directory for the source code.
-
-It consists of a relay server in the US, Europe, and Asia.
-Clients use [GeoDNS](https://en.wikipedia.org/wiki/GeoDNS) to connect to the nearest relay, and relays connect to each other to form a global mesh.
-You can also connect to individual nodes directly:
-- `https://usc.cdn.moq.dev`
-- `https://euc.cdn.moq.dev`
-- `https://sea.cdn.moq.dev`
-
-Here's a quick tl;dr of the setup:
-- [Linode](https://linode.com/) is the VM provider.
-- [GCP](https://cloud.google.com/) is the DNS provider.
-- [OpenTofu](https://opentofu.org/) (aka Terraform) sets up the infrastructure.
-- [Nix](https://nixos.org/) is used to build/cache the binaries.
-- [systemd](https://systemd.io/) runs the services.
-- [ssh](https://en.wikipedia.org/wiki/Secure_Shell) + [rsync](https://en.wikipedia.org/wiki/Rsync) are used to deploy.
-- [certbot](https://certbot.eff.org/) + [Let's Encrypt](https://letsencrypt.org/) procures TLS certificates.
-
-Any EC2-like cloud provider will work; we just need a VM with a public IP address.
-The old setup used to use [GCP](https://cloud.google.com/) but was double the cost.
-We're still using [GCP](https://cloud.google.com/blog/products/networking/dns-routing-policies-for-geo-location--weighted-round-robin) for GeoDNS because there aren't many other options and it's virtually free (unlike Cloudflare).
-
-Read the [moq-relay](/rust/moq-relay) documentation for more details on how to configure the relay server.
-
-### Hard Mode
-If you don't want to use the EZ Mode, don't worry you can build your own stack.
-MoQ should work just fine inside your own network or infrastructure provided you understand the QUIC requirements.
-
-You need at least one server with some way to discover its IP address.
-DNS is the easiest way to do this, but some other way of getting an IP address should also work.
-QUIC also has really awesome anycast support but that's a bit more advanced; reach out if you're interested.
-
-TLS is where most people get stuck.
-[See my blog post](https://moq.dev/blog/tls-and-quic) for more details, but here's the important bits:
-
-- QUIC uses the same TLS certificate as HTTPS.
-- However, TLS load balancers currently don't support QUIC, so you need to provision your own TLS certificates.
-- You can disable TLS verification if you don't care about MITM attacks, but only for native clients.
-- Web browsers can support self-signed certificates via [fingerprint verification](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport/WebTransport#servercertificatehashes), but it's limited to ephemeral certificates (<2 weeks).
-
-And of course, make sure UDP is allowed on your firewall.
-The default WebTransport port is UDP/443 but anything will work if you put it in the URL.
-
-## Web
-Whew, the hard part is over.
-The web client is pretty standard:
-
-- [@moq/lite](/typescript/moq): generic network transport
-- [@moq/hang](/typescript/hang): media library and WebComponents
-- [@moq/hang-ui](/typescript/hang-ui): optional UI components
-
-Currently, you need to use a bundler and [Vite](https://vite.dev/) is the only supported option for `@moq/hang`.
-It makes me very sad and we're working on a more universal solution, contributions welcome!
-
-**NOTE** both of these libraries are intended for client-side.
-However, `@moq/lite` can run on the server side using [Deno](https://deno.com/) or a [WebTransport polyfill](https://github.com/moq-dev/web-transport/tree/main/web-transport-ws).
-Don't even try to run `@moq/hang` on the server side or you'll run into a ton of issues, *especially* with Next.js.
-
-## Native
-Native clients are the easiest to get running, but also the most limited.
-
-We have a few integrations with popular libraries:
-- [obs](/rust/obs): OBS Studio plugin for publishing or consuming media
-- [gstreamer](/rust/gstreamer): GStreamer plugin for publishing or consuming media
-- [hang-cli](/rust/hang-cli): command-line tool for publishing media (from stdin/ffmpeg)
-
-Or you can use the core libraries directly:
-- [moq-lite](/rust/moq-lite): generic network transport
-- [hang](/rust/hang): media library (no encoding/decoding support yet)
-- [libmoq](/rust/libmoq): C bindings for [moq-lite](/rust/moq-lite) and [hang](/rust/hang)
-
-You just need to make sure UDP is allowed on your firewall.
-If the server is using a non-standard TLS certificate, you'll need to configure the client to accept it.
-
-## What's Next?
-Grats on getting MoQ running in production!
-I knew you could do it.
-
-Check out the [concepts](/concepts/) and [API](/api/) docs to dive deeper.
diff --git a/doc/typescript/examples.md b/doc/typescript/examples.md
deleted file mode 100644
index 98c3e3ed5f..0000000000
--- a/doc/typescript/examples.md
+++ /dev/null
@@ -1,383 +0,0 @@
----
-title: TypeScript Examples
-description: Code examples for TypeScript libraries
----
-
-# TypeScript Examples
-
-Code examples for common use cases with the TypeScript libraries.
-
-## Basic Examples
-
-### Simple Video Player
-
-```html
-
-
-
-
-
-
-
-
-
-
-
-```
-
-### Camera Publisher
-
-```html
-
-
-
-
-
-
-
-
-
-
-
-```
-
-### Chat Application
-
-```typescript
-import * as Moq from "@moq/lite";
-
-const connection = await Moq.connect("https://relay.example.com/anon");
-
-// Publishing messages
-const broadcast = new Moq.BroadcastProducer();
-const track = broadcast.createTrack("chat");
-
-function sendMessage(text: string) {
- const group = track.appendGroup();
- group.writeString(JSON.stringify({
- user: "Alice",
- message: text,
- timestamp: Date.now(),
- }));
- group.close();
-}
-
-connection.publish("room-123", broadcast.consume());
-
-// Subscribing to messages
-const chatBroadcast = connection.consume("room-123");
-const chatTrack = await chatBroadcast.subscribe("chat");
-
-for await (const group of chatTrack) {
- const data = await group.readString();
- if (data) {
- const message = JSON.parse(data);
- console.log(`${message.user}: ${message.message}`);
- }
-}
-```
-
-## Advanced Examples
-
-### Screen Sharing
-
-```html
-
-
-
-
-
-
- Share Screen
-
-
-
- View Screen Share
-
-
-
-
-
-```
-
-### Video Conference
-
-```html
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-### Dynamic Controls
-
-```html
-
-
-
-
-
-
-
-
-
-
-
- Play
- Pause
- Mute
- Unmute
-
-
-
-
-```
-
-### React Integration
-
-```tsx
-import { useEffect, useRef, useState } from "react";
-import "@moq/hang/watch/element";
-import type { HangWatch } from "@moq/hang";
-
-function VideoPlayer({ url, path }: { url: string; path: string }) {
- const watchRef = useRef(null);
- const [volume, setVolume] = useState(1.0);
- const [muted, setMuted] = useState(false);
-
- useEffect(() => {
- if (watchRef.current) {
- watchRef.current.volume.set(volume);
- }
- }, [volume]);
-
- useEffect(() => {
- if (watchRef.current) {
- watchRef.current.muted.set(muted);
- }
- }, [muted]);
-
- return (
-
-
-
-
-
-
- setMuted(!muted)}>
- {muted ? "Unmute" : "Mute"}
-
- setVolume(Number(e.target.value))}
- />
-
-
- );
-}
-```
-
-### SolidJS Integration
-
-Use `@moq/hang-ui` for native SolidJS components:
-
-```tsx
-import { HangWatch } from "@moq/hang-ui/watch";
-
-function VideoPlayer(props) {
- return (
-
- );
-}
-```
-
-### JavaScript API
-
-```typescript
-import * as Hang from "@moq/hang";
-
-// Create connection
-const connection = new Hang.Connection("https://relay.example.com/anon");
-
-// Wait for connection
-await connection.established;
-
-// Publish media
-const publish = new Hang.Publish.Broadcast(connection, {
- enabled: true,
- name: "alice",
- video: {
- enabled: true,
- device: "camera",
- bitrate: 2_500_000,
- framerate: 30,
- },
- audio: {
- enabled: true,
- },
-});
-
-// Subscribe to media
-const watch = new Hang.Watch.Broadcast(connection, {
- enabled: true,
- name: "bob",
- video: { enabled: true },
- audio: { enabled: true },
-});
-
-// React to state changes
-watch.video.media.subscribe((stream) => {
- if (stream) {
- videoElement.srcObject = stream;
- }
-});
-
-// Control playback
-watch.paused.set(true);
-watch.volume.set(0.8);
-```
-
-### Custom Protocol
-
-Build custom protocols on top of `@moq/lite`:
-
-```typescript
-import * as Moq from "@moq/lite";
-
-class GameStatePublisher {
- private broadcast: Moq.BroadcastProducer;
- private track: Moq.Track;
-
- constructor(private connection: Moq.Connection) {
- this.broadcast = new Moq.BroadcastProducer();
- this.track = this.broadcast.createTrack("game-state");
- this.connection.publish("game-123", this.broadcast.consume());
- }
-
- updatePlayerPosition(playerId: string, x: number, y: number) {
- const group = this.track.appendGroup();
- group.writeString(JSON.stringify({
- type: "player-move",
- playerId,
- position: { x, y },
- timestamp: Date.now(),
- }));
- group.close();
- }
-
- sendChatMessage(playerId: string, message: string) {
- const group = this.track.appendGroup();
- group.writeString(JSON.stringify({
- type: "chat",
- playerId,
- message,
- timestamp: Date.now(),
- }));
- group.close();
- }
-}
-```
-
-## Demo Application
-
-Check out the complete demo application:
-
-[hang-demo on GitHub](https://github.com/moq-dev/moq/tree/main/js/hang-demo)
-
-Features:
-- Video conferencing
-- Screen sharing
-- Text chat
-- Quality selector
-- Network statistics
-
-## Next Steps
-
-- Learn about [Web Components](/typescript/web-components)
-- Read the [@moq/hang API](/typescript/hang)
-- Read the [@moq/lite API](/typescript/lite)
-- View [Rust examples](/rust/examples)
diff --git a/doc/typescript/hang.md b/doc/typescript/hang.md
deleted file mode 100644
index 761fedffa7..0000000000
--- a/doc/typescript/hang.md
+++ /dev/null
@@ -1,243 +0,0 @@
----
-title: "@moq/hang"
-description: Media library with Web Components
----
-
-# @moq/hang
-
-[](https://www.npmjs.com/package/@moq/hang)
-[](https://www.typescriptlang.org/)
-
-High-level media library for real-time streaming using [Media over QUIC](https://moq.dev), built on top of [@moq/lite](/typescript/lite).
-
-## Overview
-
-`@moq/hang` provides:
-
-- **Web Components** - Easiest way to add MoQ to your page
-- **JavaScript API** - Advanced control for custom applications
-- **WebCodecs integration** - Hardware-accelerated encoding/decoding
-- **Reactive state** - Built on `@moq/signals`
-
-## Installation
-
-```bash
-npm add @moq/hang
-# or
-pnpm add @moq/hang
-bun add @moq/hang
-```
-
-## Web Components
-
-The fastest way to add MoQ to your web page. See [Web Components](/typescript/web-components) for details.
-
-### Publishing
-
-```html
-
-
-
-
-
-
-```
-
-### Watching
-
-```html
-
-
-
-
-
-
-```
-
-### Video Conferencing
-
-```html
-
-
-
-
-```
-
-[Learn more about Web Components →](/typescript/web-components)
-
-## JavaScript API
-
-For advanced use cases:
-
-```typescript
-import * as Hang from "@moq/hang";
-
-// Create connection
-const connection = new Hang.Connection("https://relay.example.com/anon");
-
-// Publishing media
-const publish = new Hang.Publish.Broadcast(connection, {
- enabled: true,
- name: "alice",
- video: { enabled: true, device: "camera" },
- audio: { enabled: true },
-});
-
-// Subscribing to media
-const watch = new Hang.Watch.Broadcast(connection, {
- enabled: true,
- name: "alice",
- video: { enabled: true },
- audio: { enabled: true },
-});
-
-// Everything is reactive
-publish.name.set("bob");
-watch.volume.set(0.8);
-```
-
-## Features
-
-### Real-time Latency
-
-Uses WebTransport and WebCodecs for sub-second latency:
-
-```typescript
-const watch = new Hang.Watch.Broadcast(connection, {
- name: "live-stream",
- // Latency optimizations
- video: { enabled: true },
-});
-```
-
-### Device Selection
-
-Choose camera or screen:
-
-```typescript
-const publish = new Hang.Publish.Broadcast(connection, {
- name: "my-stream",
- video: {
- enabled: true,
- device: "camera", // or "screen"
- },
-});
-
-// Switch devices
-publish.video.device.set("screen");
-```
-
-### Quality Control
-
-Control encoding quality:
-
-```typescript
-const publish = new Hang.Publish.Broadcast(connection, {
- name: "my-stream",
- video: {
- enabled: true,
- bitrate: 2_500_000, // 2.5 Mbps
- framerate: 30,
- },
-});
-```
-
-### Playback Controls
-
-```typescript
-const watch = new Hang.Watch.Broadcast(connection, {
- name: "stream",
-});
-
-// Pause/resume
-watch.paused.set(true);
-watch.paused.set(false);
-
-// Volume
-watch.muted.set(false);
-watch.volume.set(0.8);
-```
-
-## Reactive State
-
-Everything uses signals from `@moq/signals`:
-
-```typescript
-import { react } from "@moq/signals/react";
-
-const publish = document.querySelector("hang-publish") as HangPublish;
-
-// Convert to React signal
-const videoSource = react(publish.video.media);
-
-useEffect(() => {
- previewVideo.srcObject = videoSource();
-}, [videoSource]);
-```
-
-## Supported Codecs
-
-**Video:**
-- H.264 (AVC) - Best compatibility
-- H.265 (HEVC) - Better compression
-- VP8 / VP9 - Open codec
-- AV1 - Latest, best compression
-
-**Audio:**
-- Opus - Best for voice/music
-- AAC - Good compatibility
-
-Codec selection is automatic based on browser support.
-
-## Browser Support
-
-Requires:
-- **WebTransport** - Chrome 97+, Edge 97+
-- **WebCodecs** - Same browsers
-- **WebAudio** - All modern browsers
-
-## Examples
-
-Check out [hang-demo](https://github.com/moq-dev/moq/tree/main/js/hang-demo) for:
-
-- Video conferencing
-- Screen sharing
-- Chat integration
-- Quality selection UI
-
-[View more examples →](/typescript/examples)
-
-## Framework Integration
-
-Works with any framework:
-
-- **React** - Via `@moq/signals/react`
-- **SolidJS** - Via `@moq/signals/solid` or `@moq/hang-ui`
-- **Vue** - Via `@moq/signals/vue`
-- **Vanilla JS** - Direct Web Components
-
-## Protocol Specification
-
-See the [hang specification](https://moq-dev.github.io/drafts/draft-lcurley-moq-hang.html).
-
-## Next Steps
-
-- Learn about [Web Components](/typescript/web-components)
-- Use [@moq/lite](/typescript/lite) for custom protocols
-- View [code examples](/typescript/examples)
-- Deploy with the [Deployment guide](/guide/deployment)
diff --git a/doc/typescript/index.md b/doc/typescript/index.md
deleted file mode 100644
index c6e56dc807..0000000000
--- a/doc/typescript/index.md
+++ /dev/null
@@ -1,203 +0,0 @@
----
-title: TypeScript Libraries
-description: TypeScript/JavaScript implementation for browsers
----
-
-# TypeScript Libraries
-
-The TypeScript implementation brings MoQ to web browsers using modern APIs like WebTransport and WebCodecs.
-
-## Core Libraries
-
-### @moq/lite
-
-[](https://www.npmjs.com/package/@moq/lite)
-
-Core pub/sub transport protocol for browsers. Implements the [moq-lite specification](https://moq-dev.github.io/drafts/draft-lcurley-moq-lite.html).
-
-**Features:**
-- WebTransport-based QUIC
-- Broadcasts, tracks, groups, frames
-- Browser and Deno support
-
-[Learn more →](/typescript/lite)
-
-### @moq/hang
-
-[](https://www.npmjs.com/package/@moq/hang)
-
-High-level media library with Web Components for streaming audio and video.
-
-**Features:**
-- Web Components (easiest integration)
-- JavaScript API for advanced use
-- WebCodecs-based encoding/decoding
-- Reactive state management
-
-[Learn more →](/typescript/hang)
-
-## UI Components
-
-### @moq/hang-ui
-
-[](https://www.npmjs.com/package/@moq/hang-ui)
-
-SolidJS UI components that interact with hang Web Components.
-
-**Features:**
-- Quality selector
-- Playback controls
-- Chat interface
-- Network statistics
-
-## Utilities
-
-### @moq/signals
-
-Reactive signals library used by hang for state management.
-
-### @moq/clock
-
-Clock utilities for timestamp synchronization.
-
-### @moq/token
-
-JWT token generation and verification for browsers.
-
-## Installation
-
-```bash
-npm add @moq/lite
-npm add @moq/hang
-npm add @moq/hang-ui
-
-# or with other package managers
-pnpm add @moq/lite
-bun add @moq/hang
-yarn add @moq/hang
-```
-
-## Quick Start
-
-### Using Web Components
-
-The easiest way to add MoQ to your web page:
-
-```html
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-[Learn more about Web Components →](/typescript/web-components)
-
-### Using JavaScript API
-
-For more control, use the JavaScript API:
-
-```typescript
-import * as Moq from "@moq/lite";
-
-// Connect to relay
-const connection = await Moq.connect("https://relay.example.com/anon");
-
-// Create and publish a broadcast
-const broadcast = new Moq.BroadcastProducer();
-const track = broadcast.createTrack("chat");
-
-const group = track.appendGroup();
-group.writeString("Hello, MoQ!");
-group.close();
-
-connection.publish("my-broadcast", broadcast.consume());
-```
-
-[Learn more about @moq/lite →](/typescript/lite)
-
-## Browser Compatibility
-
-Requires modern browser features:
-
-- **WebTransport** - Chromium-based browsers (Chrome, Edge, Brave)
-- **WebCodecs** - For media encoding/decoding
-- **WebAudio** - For audio playback
-
-**Supported browsers:**
-- Chrome 97+
-- Edge 97+
-- Brave (recent versions)
-
-**Experimental support:**
-- Firefox (behind flag)
-- Safari (future support planned)
-
-## Framework Integration
-
-The reactive API works with popular frameworks:
-
-### React
-
-```typescript
-import react from "@moq/signals/react";
-
-const publish = document.querySelector("hang-publish") as HangPublish;
-const media = react(publish.video.media);
-
-useEffect(() => {
- video.srcObject = media();
-}, [media]);
-```
-
-### SolidJS
-
-```typescript
-import solid from "@moq/signals/solid";
-
-const publish = document.querySelector("hang-publish") as HangPublish;
-const media = solid(publish.video.media);
-
-createEffect(() => {
- video.srcObject = media();
-});
-```
-
-Use `@moq/hang-ui` for ready-made SolidJS components.
-
-## Demo Application
-
-Check out the [hang-demo](https://github.com/moq-dev/moq/tree/main/js/hang-demo) for complete examples:
-
-- Video conferencing
-- Screen sharing
-- Text chat
-- Quality selection
-
-## Next Steps
-
-- Explore [@moq/lite](/typescript/lite) - Core protocol
-- Explore [@moq/hang](/typescript/hang) - Media library
-- Learn about [Web Components](/typescript/web-components)
-- View [code examples](/typescript/examples)
diff --git a/doc/typescript/lite.md b/doc/typescript/lite.md
deleted file mode 100644
index 0f516a3e3d..0000000000
--- a/doc/typescript/lite.md
+++ /dev/null
@@ -1,248 +0,0 @@
----
-title: "@moq/lite"
-description: Core pub/sub protocol for browsers
----
-
-# @moq/lite
-
-[](https://www.npmjs.com/package/@moq/lite)
-[](https://www.typescriptlang.org/)
-
-A TypeScript implementation of [Media over QUIC](https://moq.dev/) providing real-time data delivery in web browsers. Implements the [moq-lite specification](https://moq-dev.github.io/drafts/draft-lcurley-moq-lite.html).
-
-## Overview
-
-`@moq/lite` is the browser equivalent of the Rust `moq-lite` crate, providing the core networking layer for MoQ. For higher-level media functionality, use [@moq/hang](/typescript/hang).
-
-## Installation
-
-```bash
-npm add @moq/lite
-# or
-pnpm add @moq/lite
-bun add @moq/lite
-yarn add @moq/lite
-```
-
-## Quick Start
-
-### Basic Connection
-
-```typescript
-import * as Moq from "@moq/lite";
-
-// Connect to a MoQ relay server
-const connection = await Moq.connect("https://relay.example.com/anon");
-console.log("Connected to MoQ relay!");
-```
-
-### Publishing Data
-
-```typescript
-import * as Moq from "@moq/lite";
-
-const connection = await Moq.connect("https://relay.example.com/anon");
-
-// Create a broadcast
-const broadcast = new Moq.BroadcastProducer();
-
-// Create a track
-const track = broadcast.createTrack("chat");
-
-// Send data in groups
-const group = track.appendGroup();
-group.writeString("Hello, MoQ!");
-group.close();
-
-// Publish to the relay
-connection.publish("my-broadcast", broadcast.consume());
-```
-
-### Subscribing to Data
-
-```typescript
-import * as Moq from "@moq/lite";
-
-const connection = await Moq.connect("https://relay.example.com/anon");
-
-// Subscribe to a broadcast
-const broadcast = connection.consume("my-broadcast");
-
-// Subscribe to a track
-const track = await broadcast.subscribe("chat");
-
-// Read data as it arrives
-for (;;) {
- const group = await track.nextGroup();
- if (!group) break;
-
- for (;;) {
- const frame = await group.readString();
- if (!frame) break;
-
- console.log("Received:", frame);
- }
-}
-```
-
-### Stream Discovery
-
-```typescript
-import * as Moq from "@moq/lite";
-
-const connection = await Moq.connect("https://relay.example.com/anon");
-
-// Discover broadcasts announced by the server
-let announcement = await connection.announced();
-while (announcement) {
- console.log("New stream available:", announcement.name);
-
- // Subscribe to the broadcast
- const broadcast = connection.consume(announcement.name);
- // ... handle the broadcast
-
- announcement = await connection.announced();
-}
-```
-
-## Core Concepts
-
-### Broadcasts
-
-A collection of related tracks:
-
-```typescript
-const broadcast = new Moq.BroadcastProducer();
-```
-
-### Tracks
-
-Named streams within a broadcast:
-
-```typescript
-const track = broadcast.createTrack("video");
-```
-
-### Groups
-
-Collections of frames (usually aligned with keyframes):
-
-```typescript
-const group = track.appendGroup();
-group.write(frameData);
-group.close();
-```
-
-### Frames
-
-Individual data chunks:
-
-```typescript
-// Write raw bytes
-group.write(new Uint8Array([1, 2, 3]));
-
-// Write string (convenience method)
-group.writeString("Hello!");
-```
-
-## Advanced Usage
-
-### Authentication
-
-Pass JWT tokens via query parameters:
-
-```typescript
-const connection = await Moq.connect(
- `https://relay.example.com/room/123?jwt=${token}`
-);
-```
-
-See [Authentication guide](/guide/authentication) for details.
-
-### Priority
-
-Set priority for groups:
-
-```typescript
-const group = track.appendGroup();
-group.priority = 10; // Higher priority
-group.write(keyframeData);
-```
-
-### Error Handling
-
-```typescript
-try {
- await connection.publish("my-broadcast", broadcast.consume());
-} catch (error) {
- if (error instanceof Moq.ConnectionClosedError) {
- console.error("Connection closed");
- } else if (error instanceof Moq.InvalidPathError) {
- console.error("Invalid path:", error.path);
- } else {
- console.error("Unexpected error:", error);
- }
-}
-```
-
-### Closing Connections
-
-```typescript
-// Close when done
-await connection.close();
-
-// Or handle connection close events
-connection.addEventListener("close", () => {
- console.log("Connection closed");
-});
-```
-
-## Running on Deno
-
-`@moq/lite` can also run server-side using [Deno](https://deno.com/):
-
-```typescript
-import * as Moq from "npm:@moq/lite";
-
-const connection = await Moq.connect("https://relay.example.com/anon");
-// Same API as browser
-```
-
-## TypeScript Support
-
-Full TypeScript support with type definitions:
-
-```typescript
-import type { Connection, BroadcastProducer, Track } from "@moq/lite";
-
-const connection: Connection = await Moq.connect("...");
-const broadcast: BroadcastProducer = new Moq.BroadcastProducer();
-const track: Track = broadcast.createTrack("video");
-```
-
-## Browser Compatibility
-
-Requires **WebTransport** support:
-
-- Chrome 97+
-- Edge 97+
-- Brave (recent versions)
-
-Firefox and Safari support is experimental or planned.
-
-## Examples
-
-For more examples, see:
-- [TypeScript examples](/typescript/examples)
-- [hang-demo](https://github.com/moq-dev/moq/tree/main/js/hang-demo)
-
-## Protocol Specification
-
-See the [moq-lite specification](https://moq-dev.github.io/drafts/draft-lcurley-moq-lite.html) for protocol details.
-
-## Next Steps
-
-- Build media apps with [@moq/hang](/typescript/hang)
-- Learn about [Web Components](/typescript/web-components)
-- View [code examples](/typescript/examples)
-- Read the [Architecture guide](/guide/architecture)
diff --git a/doc/typescript/web-components.md b/doc/typescript/web-components.md
deleted file mode 100644
index 61f8f46f38..0000000000
--- a/doc/typescript/web-components.md
+++ /dev/null
@@ -1,313 +0,0 @@
----
-title: Web Components
-description: Web Components API reference
----
-
-# Web Components
-
-`@moq/hang` provides Web Components for easy integration into any web page or framework.
-
-## Why Web Components?
-
-- **Framework agnostic** - Works with React, Vue, Solid, or vanilla JS
-- **Easy integration** - Just import and use like HTML
-- **Encapsulated** - Shadow DOM for style isolation
-- **Reactive** - Automatically update when attributes change
-
-## Available Components
-
-### ``
-
-Publish camera/microphone or screen as a MoQ broadcast.
-
-**Attributes:**
-- `url` (required) - Relay server URL
-- `path` (required) - Broadcast path/name
-- `device` - "camera" or "screen" (default: "camera")
-- `audio` - Enable audio capture (boolean)
-- `video` - Enable video capture (boolean)
-- `controls` - Show publishing controls (boolean)
-
-**Example:**
-
-```html
-
-
-
-
-
-
-```
-
-### ``
-
-Subscribe to and render a MoQ broadcast.
-
-**Attributes:**
-- `url` (required) - Relay server URL
-- `path` (required) - Broadcast path/name
-- `controls` - Show playback controls (boolean)
-- `paused` - Pause playback (boolean)
-- `muted` - Mute audio (boolean)
-- `volume` - Audio volume (0-1, default: 1)
-
-**Example:**
-
-```html
-
-
-
-
-
-
-```
-
-### ``
-
-Video conferencing component that discovers and renders multiple broadcasts.
-
-**Attributes:**
-- `url` (required) - Relay server URL
-- `path` (required) - Room path prefix
-- `audio` - Enable audio (boolean)
-- `video` - Enable video (boolean)
-- `controls` - Show controls (boolean)
-
-**Example:**
-
-```html
-
-
-
-
-
-
-```
-
-This discovers any broadcasts starting with `room123/` and renders them in a grid.
-
-### ``
-
-Display browser support information.
-
-**Attributes:**
-- `mode` - "publish" or "watch"
-- `show` - "always", "partial", or "never" (default: "partial")
-
-**Example:**
-
-```html
-
-
-
-
-```
-
-## Using JavaScript Properties
-
-HTML attributes are strings, but JavaScript properties are typed and reactive:
-
-```typescript
-// Get element reference
-const watch = document.querySelector("hang-watch") as HangWatch;
-
-// Set properties (reactive)
-watch.volume.set(0.8);
-watch.muted.set(false);
-watch.paused.set(true);
-
-// Subscribe to changes
-watch.volume.subscribe((vol) => {
- console.log("Volume changed:", vol);
-});
-
-// Get current value
-const currentVolume = watch.volume.get();
-```
-
-## Reactive Properties
-
-All properties are signals from `@moq/signals`:
-
-```typescript
-import { HangWatch } from "@moq/hang/watch/element";
-
-const watch = document.querySelector("hang-watch") as HangWatch;
-
-// These are all reactive signals:
-watch.volume // Signal
-watch.muted // Signal
-watch.paused // Signal
-watch.url // Signal
-watch.path // Signal
-```
-
-## Framework Integration
-
-### React
-
-```tsx
-import { useEffect, useRef } from "react";
-import "@moq/hang/watch/element";
-
-function VideoPlayer({ url, path }) {
- const ref = useRef(null);
-
- useEffect(() => {
- if (ref.current) {
- ref.current.volume.set(0.8);
- }
- }, []);
-
- return (
-
-
-
- );
-}
-```
-
-### SolidJS
-
-Use `@moq/hang-ui` for native SolidJS components, or use Web Components directly:
-
-```tsx
-import "@moq/hang/watch/element";
-
-function VideoPlayer(props) {
- return (
-
-
-
- );
-}
-```
-
-### Vue
-
-```vue
-
-
-
-
-
-
-
-```
-
-## Styling
-
-Web Components use Shadow DOM, so global styles won't apply. Use CSS custom properties (variables) or style child elements:
-
-```html
-
-
-
-
-
-```
-
-## Tree-Shaking
-
-To prevent tree-shaking from removing component registrations, explicitly import with `/element` suffix:
-
-```typescript
-// Correct
-import "@moq/hang/watch/element";
-
-// May be tree-shaken (don't use)
-import "@moq/hang";
-```
-
-## TypeScript Support
-
-Full TypeScript support with type definitions:
-
-```typescript
-import type { HangWatch, HangPublish, HangMeet } from "@moq/hang";
-
-const watch: HangWatch = document.querySelector("hang-watch")!;
-const publish: HangPublish = document.querySelector("hang-publish")!;
-```
-
-## Events
-
-Components emit custom events:
-
-```typescript
-const watch = document.querySelector("hang-watch") as HangWatch;
-
-watch.addEventListener("play", () => {
- console.log("Playback started");
-});
-
-watch.addEventListener("pause", () => {
- console.log("Playback paused");
-});
-
-watch.addEventListener("error", (e) => {
- console.error("Error:", e.detail);
-});
-```
-
-## Examples
-
-See the [hang-demo](https://github.com/moq-dev/moq/tree/main/js/hang-demo) for complete examples:
-
-- Basic video player
-- Video conferencing
-- Screen sharing
-- Chat integration
-
-[View more examples →](/typescript/examples)
-
-## Next Steps
-
-- Try the [JavaScript API](/typescript/hang)
-- View [@moq/hang-ui](https://www.npmjs.com/package/@moq/hang-ui) for SolidJS components
-- Read [code examples](/typescript/examples)
-- Learn about [@moq/lite](/typescript/lite) for custom protocols
diff --git a/flake.lock b/flake.lock
index 8afcac5b05..329eac36a1 100644
--- a/flake.lock
+++ b/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
- "lastModified": 1766774972,
- "narHash": "sha256-8qxEFpj4dVmIuPn9j9z6NTbU+hrcGjBOvaxTzre5HmM=",
+ "lastModified": 1777335812,
+ "narHash": "sha256-bEg5xoAxAwsyfnGhkEX7RJViTIBIYPd8ISg4O1c0HFc=",
"owner": "ipetkov",
"repo": "crane",
- "rev": "01bc1d404a51a0a07e9d8759cd50a7903e218c82",
+ "rev": "5e0fb2f64edff2822249f21293b8304dedaaf676",
"type": "github"
},
"original": {
@@ -35,11 +35,11 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1767242400,
- "narHash": "sha256-knFaYjeg7swqG1dljj1hOxfg39zrIy8pfGuicjm9s+o=",
+ "lastModified": 1777425547,
+ "narHash": "sha256-d57AbflkNfZNoFaZDzssEq1RfPoM9dLtOGI2O+N/68Q=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "c04833a1e584401bb63c1a63ddc51a71e6aa457a",
+ "rev": "ebc08544afa77957cc348ba72dc490ec73b87f68",
"type": "github"
},
"original": {
@@ -64,11 +64,11 @@
]
},
"locked": {
- "lastModified": 1767236000,
- "narHash": "sha256-l4ftzyoD7XX0biEoTEwhOkFhvHhmggkVQyl2jEES0so=",
+ "lastModified": 1777605393,
+ "narHash": "sha256-Hjp0VOOHgHcTrX23iVvnfAudPcuCmfkfpQNFwv2v/ks=",
"owner": "oxalica",
"repo": "rust-overlay",
- "rev": "2be15c4abeba7ade2cf9bc4c17ab310441e533b9",
+ "rev": "ff88db34cfa486fc4964a6991cab1678d82eee8c",
"type": "github"
},
"original": {
diff --git a/flake.nix b/flake.nix
index 950ddaf251..14e8e5a8bf 100644
--- a/flake.nix
+++ b/flake.nix
@@ -1,6 +1,21 @@
{
description = "MoQ - Media over QUIC";
+ # Pre-built binaries live in our Cachix cache. Only tagged releases are
+ # pushed (CI fires on moq-relay-v*, moq-cli-v*, etc.), so pin the flake ref
+ # to a recent tag to get a hit. The default branch HEAD is not cached and
+ # builds from source:
+ # nix run github:moq-dev/moq/moq-relay-v0.12.4#moq-relay --accept-flake-config
+ #
+ # --accept-flake-config opts into the nixConfig below for one command. To
+ # trust the cache permanently instead, run: cachix use kixelated
+ nixConfig = {
+ extra-substituters = [ "https://kixelated.cachix.org" ];
+ extra-trusted-public-keys = [
+ "kixelated.cachix.org-1:CmFcV0lyM6KuVM2m9mih0q4SrAa0XyCsiM7GHrz3KKk="
+ ];
+ };
+
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
@@ -40,27 +55,58 @@
"rust-src"
"rust-analyzer"
];
+ targets = pkgs.lib.optionals pkgs.stdenv.isDarwin [
+ "x86_64-apple-darwin"
+ "aarch64-apple-darwin"
+ ];
};
- # Rust dependencies
- rustDeps = with pkgs; [
- rust-toolchain
- just
- pkg-config
- glib
- libressl
- ffmpeg
- curl
- cargo-sort
- cargo-shear
- cargo-edit
- cargo-hack
+ # GStreamer dependencies (for moq-gst plugin)
+ gstreamerDeps = with pkgs; [
+ gst_all_1.gstreamer
+ gst_all_1.gstreamer.dev
+ gst_all_1.gst-plugins-base
+ gst_all_1.gst-plugins-good
+ gst_all_1.gst-plugins-bad
];
+ # Rust dependencies
+ rustDeps =
+ with pkgs;
+ [
+ rust-toolchain
+ just
+ git
+ cmake
+ pkg-config
+ glib
+ libressl
+ ffmpeg
+ curl
+ cargo-sort
+ cargo-shear
+ cargo-edit
+ cargo-sweep
+ cargo-semver-checks
+ cargo-deny
+ ]
+ ++ gstreamerDeps
+ ++ pkgs.lib.optionals (!pkgs.stdenv.isDarwin) [
+ # Marked broken on Darwin in nixpkgs, but builds fine on Linux.
+ pkgs.release-plz
+ ];
+
# JavaScript dependencies
jsDeps = with pkgs; [
bun
- deno
+ # Only for NPM publishing
+ nodejs_24
+ ];
+
+ # Python dependencies
+ pyDeps = with pkgs; [
+ uv
+ python3
];
# CDN/deployment dependencies
@@ -68,6 +114,45 @@
opentofu
];
+ # Tools for producing .deb/.rpm artifacts. Cross-platform so that
+ # `just rs package` works from `nix develop` on both Linux and macOS.
+ packagingDeps = with pkgs; [
+ nfpm
+ dpkg
+ gettext
+
+ # cargo-zigbuild + zig let CI build a single binary that links
+ # against an older glibc (passed as `.`), so the
+ # same artifact ships in both .deb and .rpm. No docker needed.
+ cargo-zigbuild
+ zig
+ ];
+
+ # Tools needed to regenerate and sign the apt/rpm repositories.
+ # Linux-only because apt and createrepo_c are marked broken on Darwin
+ # in nixpkgs. The publish workflows only ever run on Linux runners.
+ publishDeps =
+ with pkgs;
+ lib.optionals (!stdenv.isDarwin) [
+ apt
+ createrepo_c
+ rpm
+ rclone
+ gnupg
+ gzip
+ ];
+
+ # Linters / formatters required by `just ci`; `just check` and
+ # `just fix` guard each tool with `command -v` so they skip
+ # silently when the binary isn't on $PATH.
+ lintDeps = with pkgs; [
+ shellcheck
+ shfmt
+ actionlint
+ taplo
+ nixfmt
+ ];
+
# Apply our overlay to get the package definitions
overlayPkgs = pkgs.extend self.overlays.default;
in
@@ -77,23 +162,48 @@
name = "moq-all";
paths = [
moq-relay
- moq-clock
- hang
- moq-token
+ moq-cli
+ moq-token-cli
];
};
# Inherit packages from the overlay
inherit (overlayPkgs)
moq-relay
- moq-clock
- hang
- moq-token
+ moq-cli
+ moq-token-cli
+ moq-boy
+ libmoq
+ moq-gst
;
+
+ # Bundle of packaging + repo-publish tooling, pinned via flake.lock.
+ # CI builds this and prepends its bin/ to $PATH so subsequent steps
+ # use the same versions a local `nix develop` user would.
+ packaging = pkgs.symlinkJoin {
+ name = "moq-packaging-tools";
+ paths = packagingDeps ++ publishDeps;
+ };
+ };
+
+ # Re-export gst_all_1 so users can pair the plugin with a matching
+ # gstreamer in one nix invocation:
+ # nix shell .#moq-gst .#gst_all_1.gstreamer --command gst-inspect-1.0 moq
+ # Sourcing from the same nixpkgs the moq-gst build linked against
+ # avoids the duplicate-symbol crash you get with
+ # `nixpkgs#gst_all_1.gstreamer`, which can resolve to a different
+ # store hash. Lives under legacyPackages because nested attrsets
+ # are disallowed in the flake `packages` schema.
+ legacyPackages = {
+ inherit (pkgs) gst_all_1;
};
devShells.default = pkgs.mkShell {
- packages = rustDeps ++ jsDeps ++ cdnDeps;
+ packages = rustDeps ++ jsDeps ++ pyDeps ++ cdnDeps ++ packagingDeps ++ lintDeps;
+
+ # jemalloc's configure uses -O0 test builds, which conflict with
+ # Nix's _FORTIFY_SOURCE hardening (requires -O).
+ hardeningDisable = [ "fortify" ];
shellHook = ''
export LIBCLANG_PATH="${pkgs.libclang.lib}/lib"
diff --git a/go/.gitignore b/go/.gitignore
new file mode 100644
index 0000000000..bd76384a71
--- /dev/null
+++ b/go/.gitignore
@@ -0,0 +1,14 @@
+# Generated by scripts/{check,package}.sh; the main repo stays binary-free.
+moq/moq.go
+moq/lib/
+
+# Native libraries should never land in source control.
+*.a
+*.so
+*.dylib
+*.dll
+*.lib
+
+# Local Go build cache / vendoring.
+go.sum
+vendor/
diff --git a/go/README.md b/go/README.md
new file mode 100644
index 0000000000..57f6bc4c0d
--- /dev/null
+++ b/go/README.md
@@ -0,0 +1,47 @@
+# Moq (Go)
+
+Go bindings for [Media over QUIC](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/), generated from [rs/moq-ffi](../rs/moq-ffi) via [uniffi-bindgen-go](https://github.com/NordSecurity/uniffi-bindgen-go).
+
+The published module lives at [moq-dev/moq-go](https://github.com/moq-dev/moq-go), populated by CI on every `moq-ffi-v*` tag. This in-tree directory is the source skeleton, not a buildable Go module on its own.
+
+## Install
+
+```bash
+go get github.com/moq-dev/moq-go@v0.2.11
+```
+
+```go
+import "github.com/moq-dev/moq-go/moq"
+```
+
+The published module ships prebuilt `libmoq_ffi.a` for `linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, and `windows/amd64`. cgo selects the right archive at link time via build tags; no `LD_LIBRARY_PATH` or extra setup needed.
+
+## Local development
+
+`go/scripts/check.sh` builds `moq-ffi` for the host, runs `uniffi-bindgen-go` to regenerate `moq.go`, stages the in-tree source + host `libmoq_ffi.a` into `dist/go-pkg/`, and runs `go build`/`go vet`/`go test` from there. Run via `just go check`. Skips cleanly without `cargo`, `go`, or `uniffi-bindgen-go`.
+
+Install `uniffi-bindgen-go` once:
+
+```bash
+cargo install uniffi-bindgen-go \
+ --git https://github.com/NordSecurity/uniffi-bindgen-go \
+ --tag v0.7.1+v0.31.0
+```
+
+## Layout
+
+```text
+go/
+ go.mod Module declaration (github.com/moq-dev/moq-go)
+ moq/
+ cgo.go Per-platform cgo LDFLAGS only — committed
+ moq.go Generated by uniffi-bindgen-go (gitignored, staged at release time)
+ lib/_/libmoq_ffi.a (gitignored, staged at release time)
+ scripts/ check.sh, package.sh, publish.sh
+```
+
+Compiled binaries never live in the source tree. `scripts/check.sh` stages into the gitignored `dist/` working dir under the repo root; CI does the equivalent assembly into the [moq-dev/moq-go](https://github.com/moq-dev/moq-go) mirror.
+
+## Release
+
+The `release-go.yml` workflow fires on every `moq-ffi-v*` tag, builds per-target static libraries, runs `uniffi-bindgen-go`, calls `go/scripts/package.sh` to assemble the module, and `go/scripts/publish.sh` to push the result to `moq-dev/moq-go` with a bare-semver tag (e.g. `v0.2.11`). Go's module proxy picks up the new tag automatically.
diff --git a/go/go.mod b/go/go.mod
new file mode 100644
index 0000000000..6d9c4ebb4f
--- /dev/null
+++ b/go/go.mod
@@ -0,0 +1,3 @@
+module github.com/moq-dev/moq-go
+
+go 1.21
diff --git a/go/justfile b/go/justfile
new file mode 100644
index 0000000000..9647d28292
--- /dev/null
+++ b/go/justfile
@@ -0,0 +1,33 @@
+#!/usr/bin/env just --justfile
+#
+# Language-specific recipes for the Go bindings under go/. Invoked from
+# the repo root as `just go ` via the `mod go` import.
+
+set working-directory := '.'
+
+default:
+ just check
+
+# Build moq-ffi for the host, run uniffi-bindgen-go, stage everything
+# into a tmp dir, and run `go build`/`go vet`/`go test` from there.
+# Skips cleanly if cargo, go, or uniffi-bindgen-go is missing.
+check:
+ bash scripts/check.sh
+
+# Stage the in-tree go/ source + per-target moq-ffi libs + generated
+# bindings into a single Go module ready for publish.
+package *args:
+ bash scripts/package.sh {{ args }}
+
+# Full Go CI: `check` builds moq-ffi, regenerates bindings, runs go
+# vet/build/test. Takes a newline-separated list of changed files;
+# skips if FILES is non-empty and none match the Go scope. Run
+# `just go ci` (no FILES) to force-run.
+ci FILES="":
+ #!/usr/bin/env bash
+ set -euo pipefail
+ if [[ -n "{{ FILES }}" ]] && ! echo "{{ FILES }}" | grep -qE '^(go/|rs/moq-ffi/)'; then
+ echo "go: no Go changes; skipping."
+ exit 0
+ fi
+ just check
diff --git a/go/moq/cgo.go b/go/moq/cgo.go
new file mode 100644
index 0000000000..a12d12853e
--- /dev/null
+++ b/go/moq/cgo.go
@@ -0,0 +1,20 @@
+// Package moq provides Go bindings for Media over QUIC.
+//
+// The exported API is generated from rs/moq-ffi via uniffi-bindgen-go and
+// dropped in alongside this file (moq.go) by scripts/package.sh; the in-tree
+// source therefore does not build on its own. Run scripts/check.sh to stage a
+// complete copy into dist/ and exercise it.
+//
+// The per-platform static archive is loaded from moq/lib/_/
+// inside the staged module, populated by scripts/package.sh from the release
+// build matrix.
+package moq
+
+/*
+#cgo linux,amd64 LDFLAGS: -L${SRCDIR}/lib/linux_amd64 -lmoq_ffi -ldl -lm -lpthread
+#cgo linux,arm64 LDFLAGS: -L${SRCDIR}/lib/linux_arm64 -lmoq_ffi -ldl -lm -lpthread
+#cgo darwin,amd64 LDFLAGS: -L${SRCDIR}/lib/darwin_amd64 -lmoq_ffi -framework Security -framework SystemConfiguration -framework CoreFoundation
+#cgo darwin,arm64 LDFLAGS: -L${SRCDIR}/lib/darwin_arm64 -lmoq_ffi -framework Security -framework SystemConfiguration -framework CoreFoundation
+#cgo windows,amd64 LDFLAGS: -L${SRCDIR}/lib/windows_amd64 -lmoq_ffi -lws2_32 -luserenv -lbcrypt -lntdll
+*/
+import "C"
diff --git a/go/scripts/check.sh b/go/scripts/check.sh
new file mode 100755
index 0000000000..51dcfd07a7
--- /dev/null
+++ b/go/scripts/check.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Local smoke check for the Go module.
+#
+# Builds moq-ffi for the host, runs uniffi-bindgen-go, stages everything
+# into a tmp dir under the workspace's dist/, and runs `go build`/`go vet`/
+# `go test`. Intended for `just go check`.
+#
+# The main repo stays binary-free: no `.a` or generated `.go` files land
+# in go/ during local development. Everything happens in dist/, which is
+# already gitignored at the repo root.
+#
+# Skipped cleanly on hosts without `go`, `cargo`, or `uniffi-bindgen-go`.
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+GO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+WORKSPACE_DIR="$(cd "$GO_DIR/.." && pwd)"
+
+if ! command -v go >/dev/null 2>&1; then
+ echo "go check: no go on PATH, skipping" >&2
+ exit 0
+fi
+if ! command -v cargo >/dev/null 2>&1; then
+ echo "go check: no cargo on PATH, skipping" >&2
+ exit 0
+fi
+if ! command -v uniffi-bindgen-go >/dev/null 2>&1; then
+ echo "go check: uniffi-bindgen-go not on PATH, skipping" >&2
+ echo " install: cargo install uniffi-bindgen-go --git https://github.com/NordSecurity/uniffi-bindgen-go --tag v0.7.1+v0.31.0" >&2
+ exit 0
+fi
+
+HOST_TARGET=$(rustc -vV | awk '/^host:/ {print $2}')
+echo "go check: building moq-ffi for $HOST_TARGET..."
+cargo build --release --package moq-ffi \
+ --manifest-path "$WORKSPACE_DIR/Cargo.toml"
+
+TARGET_BASE=$(cargo metadata --format-version 1 --manifest-path "$WORKSPACE_DIR/Cargo.toml" --no-deps |
+ sed -n 's/.*"target_directory":"\([^"]*\)".*/\1/p')
+
+case "$HOST_TARGET" in
+ *-apple-*)
+ CDYLIB="$TARGET_BASE/release/libmoq_ffi.dylib"
+ STATICLIB="$TARGET_BASE/release/libmoq_ffi.a"
+ ;;
+ *-windows-*)
+ CDYLIB="$TARGET_BASE/release/moq_ffi.dll"
+ STATICLIB="$TARGET_BASE/release/moq_ffi.lib"
+ ;;
+ *)
+ CDYLIB="$TARGET_BASE/release/libmoq_ffi.so"
+ STATICLIB="$TARGET_BASE/release/libmoq_ffi.a"
+ ;;
+esac
+
+[[ -f "$CDYLIB" ]] || {
+ echo "go check: cdylib not found at $CDYLIB" >&2
+ exit 1
+}
+[[ -f "$STATICLIB" ]] || {
+ echo "go check: staticlib not found at $STATICLIB" >&2
+ exit 1
+}
+
+# Reject unsupported hosts up front; package.sh derives the cgo
+# subdir name from the cargo target via its own mapping.
+case "$HOST_TARGET" in
+ x86_64-unknown-linux-gnu | aarch64-unknown-linux-gnu | x86_64-apple-darwin | aarch64-apple-darwin | x86_64-pc-windows-msvc) ;;
+ *)
+ echo "go check: unsupported host target $HOST_TARGET" >&2
+ exit 1
+ ;;
+esac
+
+# Stage into the workspace's dist/ (gitignored at repo root).
+STAGE_PARENT="$WORKSPACE_DIR/dist"
+STAGE_LIBS="$STAGE_PARENT/go-libs/$HOST_TARGET"
+STAGE_BINDINGS="$STAGE_PARENT/go-bindings"
+STAGE_PKG="$STAGE_PARENT/go-pkg"
+rm -rf "$STAGE_LIBS" "$STAGE_BINDINGS" "$STAGE_PKG"
+mkdir -p "$STAGE_LIBS" "$STAGE_BINDINGS"
+
+cp "$STATICLIB" "$STAGE_LIBS/"
+
+echo "go check: generating bindings..."
+uniffi-bindgen-go --library "$CDYLIB" --out-dir "$STAGE_BINDINGS"
+
+# Re-shape bindings dir to match package.sh's --bindings-dir expectation
+# (which wants moq/ directly). Some uniffi-bindgen-go versions nest under
+# uniffi/moq/; copy the whole dir so moq.h rides along with moq.go.
+if [[ -d "$STAGE_BINDINGS/uniffi/moq" && ! -d "$STAGE_BINDINGS/moq" ]]; then
+ cp -R "$STAGE_BINDINGS/uniffi/moq" "$STAGE_BINDINGS/moq"
+fi
+
+echo "go check: assembling module..."
+bash "$GO_DIR/scripts/package.sh" \
+ --version "0.0.0-dev" \
+ --lib-dir "$STAGE_PARENT/go-libs" \
+ --bindings-dir "$STAGE_BINDINGS" \
+ --output "$STAGE_PKG" \
+ --no-archive
+
+cd "$STAGE_PKG/moq-ffi-0.0.0-dev-go"
+echo "go check: go vet ./..."
+CGO_ENABLED=1 go vet ./...
+echo "go check: go build ./..."
+CGO_ENABLED=1 go build ./...
+echo "go check: go test ./..."
+CGO_ENABLED=1 go test ./...
+echo "go check: ok"
diff --git a/go/scripts/package.sh b/go/scripts/package.sh
new file mode 100755
index 0000000000..0627bcefa3
--- /dev/null
+++ b/go/scripts/package.sh
@@ -0,0 +1,187 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Assemble the moq-ffi Go module: copy the in-tree source skeleton, drop
+# in the uniffi-bindgen-go output, bundle per-target static libs into
+# moq/lib/_/, and tar the result.
+#
+# Designed to run after rs/moq-ffi/build.sh produces per-target outputs
+# in $LIB_DIR (one subdir per cargo target) and the uniffi-bindgen-go
+# output at $BINDINGS_DIR/moq/moq.go.
+#
+# Usage:
+# go/scripts/package.sh --version 0.0.0-dev --lib-dir libs --bindings-dir bindings --output dist
+#
+# Expected $LIB_DIR layout (per cargo target):
+# $LIB_DIR/x86_64-unknown-linux-gnu/libmoq_ffi.a
+# $LIB_DIR/aarch64-unknown-linux-gnu/libmoq_ffi.a
+# $LIB_DIR/x86_64-apple-darwin/libmoq_ffi.a
+# $LIB_DIR/aarch64-apple-darwin/libmoq_ffi.a
+# $LIB_DIR/x86_64-pc-windows-msvc/moq_ffi.lib
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+GO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+WORKSPACE_DIR="$(cd "$GO_DIR/.." && pwd)"
+
+VERSION=""
+LIB_DIR=""
+BINDINGS_DIR=""
+OUTPUT_DIR=""
+ARCHIVE=true
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --version)
+ VERSION="$2"
+ shift 2
+ ;;
+ --lib-dir)
+ LIB_DIR="$2"
+ shift 2
+ ;;
+ --bindings-dir)
+ BINDINGS_DIR="$2"
+ shift 2
+ ;;
+ --output)
+ OUTPUT_DIR="$2"
+ shift 2
+ ;;
+ --no-archive)
+ ARCHIVE=false
+ shift
+ ;;
+ -h | --help)
+ grep '^#' "$0" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ exit 1
+ ;;
+ esac
+done
+
+[[ -z "$VERSION" ]] && {
+ echo "Error: --version is required" >&2
+ exit 1
+}
+[[ -z "$LIB_DIR" ]] && {
+ echo "Error: --lib-dir is required" >&2
+ exit 1
+}
+[[ -z "$BINDINGS_DIR" ]] && {
+ echo "Error: --bindings-dir is required" >&2
+ exit 1
+}
+[[ -z "$OUTPUT_DIR" ]] && OUTPUT_DIR="dist"
+
+mkdir -p "$OUTPUT_DIR"
+OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
+
+PKG_NAME="moq-ffi-${VERSION}-go"
+PKG_STAGE="$OUTPUT_DIR/$PKG_NAME"
+rm -rf "$PKG_STAGE"
+mkdir -p "$PKG_STAGE/moq/lib"
+
+# --- 1. Copy in-tree source ---
+cp "$GO_DIR/go.mod" "$PKG_STAGE/"
+cp "$GO_DIR/README.md" "$PKG_STAGE/"
+# Copy hand-written .go files (cgo.go and anything else that lands later).
+# Skip the generated moq.go from the source tree (gitignored, shouldn't
+# exist there, but defensive).
+for f in "$GO_DIR"/moq/*.go; do
+ [[ "$(basename "$f")" == "moq.go" ]] && continue
+ cp "$f" "$PKG_STAGE/moq/"
+done
+
+# Dual-license files lifted from the workspace root.
+for license in LICENSE-MIT LICENSE-APACHE; do
+ [[ -f "$WORKSPACE_DIR/$license" ]] || {
+ echo "Error: missing $WORKSPACE_DIR/$license" >&2
+ exit 1
+ }
+ cp "$WORKSPACE_DIR/$license" "$PKG_STAGE/$license"
+done
+
+# --- 2. Generated uniffi-bindgen-go output ---
+# uniffi-bindgen-go emits both moq.go and a C header moq.h. moq.go's cgo
+# preamble does `#include `, so the header must ship alongside it or
+# consumers hit `fatal error: 'moq.h' file not found` on `go build`.
+GENERATED_GO="$BINDINGS_DIR/moq/moq.go"
+GENERATED_H="$BINDINGS_DIR/moq/moq.h"
+[[ -f "$GENERATED_GO" ]] || {
+ echo "Error: uniffi-bindgen-go output not found at $GENERATED_GO" >&2
+ exit 1
+}
+[[ -f "$GENERATED_H" ]] || {
+ echo "Error: generated C header not found at $GENERATED_H" >&2
+ exit 1
+}
+cp "$GENERATED_GO" "$PKG_STAGE/moq/moq.go"
+cp "$GENERATED_H" "$PKG_STAGE/moq/moq.h"
+
+# --- 3. Per-target static libraries ---
+# Entries are ":_:"; the GOOS/GOARCH
+# subdir name matches the cgo build tags in moq/cgo.go.
+GO_LIBS=(
+ "x86_64-unknown-linux-gnu:linux_amd64:libmoq_ffi.a"
+ "aarch64-unknown-linux-gnu:linux_arm64:libmoq_ffi.a"
+ "x86_64-apple-darwin:darwin_amd64:libmoq_ffi.a"
+ "aarch64-apple-darwin:darwin_arm64:libmoq_ffi.a"
+ "x86_64-pc-windows-msvc:windows_amd64:moq_ffi.lib"
+)
+STAGED_ANY=false
+for entry in "${GO_LIBS[@]}"; do
+ target="${entry%%:*}"
+ rest="${entry#*:}"
+ goarch="${rest%%:*}"
+ libname="${rest##*:}"
+ src="$LIB_DIR/$target/$libname"
+ if [[ -f "$src" ]]; then
+ dest="$PKG_STAGE/moq/lib/$goarch"
+ mkdir -p "$dest"
+ cp "$src" "$dest/"
+ echo " go lib $goarch <- $target"
+ STAGED_ANY=true
+ else
+ echo " go lib $goarch: skipped, $src missing"
+ fi
+done
+
+if [[ "$STAGED_ANY" != true ]]; then
+ echo "Error: no per-target libs were staged; check --lib-dir layout" >&2
+ exit 1
+fi
+
+# --- 4. Minimal consumer-facing README rewrite ---
+# The full developer README lives in the monorepo; the staged copy
+# (which ends up on moq-dev/moq-go) gets a thin orientation pointer.
+cat >"$PKG_STAGE/README.md" <` tags, not the
+# prefixed `moq-ffi-v*` tags used in this monorepo.
+#
+# Required environment:
+# BUILD_VERSION - version string (e.g. 0.2.11)
+# GO_MIRROR_TOKEN - PAT or GitHub App token with contents:write
+# on $MIRROR_REPO
+#
+# Optional environment:
+# GO_MIRROR_REPO - defaults to moq-dev/moq-go
+# GIT_AUTHOR_NAME - defaults to "moq-go-release"
+# GIT_AUTHOR_EMAIL - defaults to "release@moq.dev"
+#
+# Flags:
+# --dry-run Stage and diff against the mirror but skip the
+# commit, tag, and push. Useful for verifying the
+# pipeline locally without touching the mirror.
+#
+# Expects the staged Go module tarball under `go-out/`, produced by
+# package.sh as `moq-ffi-${BUILD_VERSION}-go.tar.gz`.
+
+DRY_RUN=false
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --dry-run)
+ DRY_RUN=true
+ shift
+ ;;
+ -h | --help)
+ grep '^#' "$0" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ exit 1
+ ;;
+ esac
+done
+
+: "${BUILD_VERSION:?BUILD_VERSION is required}"
+if [[ "$DRY_RUN" != true ]]; then
+ : "${GO_MIRROR_TOKEN:?GO_MIRROR_TOKEN is required (or pass --dry-run)}"
+fi
+
+MIRROR_REPO="${GO_MIRROR_REPO:-moq-dev/moq-go}"
+# Go modules resolve bare v tags; the moq-ffi-v* prefix used in
+# this monorepo would be invisible to the proxy.
+MIRROR_TAG="v${BUILD_VERSION}"
+SOURCE_TAG="moq-ffi-v${BUILD_VERSION}"
+
+TARBALL="go-out/moq-ffi-${BUILD_VERSION}-go.tar.gz"
+[[ -f "$TARBALL" ]] || {
+ echo "Error: missing $TARBALL" >&2
+ exit 1
+}
+
+WORK=$(mktemp -d)
+trap 'rm -rf "$WORK"' EXIT
+
+# --- 1. Clone the mirror ---
+if [[ -n "${GO_MIRROR_TOKEN:-}" ]]; then
+ CLONE_URL="https://x-access-token:${GO_MIRROR_TOKEN}@github.com/${MIRROR_REPO}"
+else
+ CLONE_URL="https://github.com/${MIRROR_REPO}"
+fi
+git clone --depth 1 "$CLONE_URL" "$WORK/mirror" 2>&1 | sed "s|${GO_MIRROR_TOKEN:-__no_token__}|***|g"
+
+# --- 2. Idempotency: skip if the mirror tag already exists ---
+if [[ -n "$(git -C "$WORK/mirror" ls-remote --tags origin "refs/tags/${MIRROR_TAG}")" ]]; then
+ echo "Mirror tag ${MIRROR_TAG} already exists on ${MIRROR_REPO}. Nothing to publish."
+ exit 0
+fi
+
+# --- 3. Extract staged package ---
+tar -xzf "$TARBALL" -C "$WORK"
+STAGED="$WORK/moq-ffi-${BUILD_VERSION}-go"
+[[ -d "$STAGED" ]] || {
+ echo "Error: tarball did not contain $STAGED" >&2
+ exit 1
+}
+
+# --- 4. Replace mirror tree with staged contents (preserving .git) ---
+rsync --archive --delete --exclude='.git' "$STAGED/" "$WORK/mirror/"
+
+# --- 5. Summary diff (always shown, helpful for dry-runs and audit logs) ---
+echo "--- diff against ${MIRROR_REPO} HEAD ---"
+git -C "$WORK/mirror" add -A
+git -C "$WORK/mirror" diff --cached --stat
+echo "---"
+
+# --- 6. Commit / tag / push (skipped in dry-run) ---
+if [[ "$DRY_RUN" == true ]]; then
+ echo "Dry-run: not committing or pushing."
+ exit 0
+fi
+
+if git -C "$WORK/mirror" diff --cached --quiet; then
+ echo "No changes to publish to ${MIRROR_REPO}. (Tag ${MIRROR_TAG} would be a no-op commit.)"
+ git -C "$WORK/mirror" tag "${MIRROR_TAG}"
+ git -C "$WORK/mirror" push origin "refs/tags/${MIRROR_TAG}"
+ exit 0
+fi
+
+git -C "$WORK/mirror" config user.name "${GIT_AUTHOR_NAME:-moq-go-release}"
+git -C "$WORK/mirror" config user.email "${GIT_AUTHOR_EMAIL:-release@moq.dev}"
+
+git -C "$WORK/mirror" commit -m "Release ${MIRROR_TAG} (mirrors ${SOURCE_TAG})"
+git -C "$WORK/mirror" tag "${MIRROR_TAG}"
+git -C "$WORK/mirror" push origin "HEAD:refs/heads/main"
+git -C "$WORK/mirror" push origin "refs/tags/${MIRROR_TAG}"
+
+echo "Published ${MIRROR_REPO}@${MIRROR_TAG}"
diff --git a/infra/README.md b/infra/README.md
new file mode 100644
index 0000000000..f35f3a09be
--- /dev/null
+++ b/infra/README.md
@@ -0,0 +1,106 @@
+# MoQ infra
+
+Cloudflare Workers and supporting scripts that back project-owned
+infrastructure.
+
+| Worker | Domain | R2 bucket | What it serves |
+| ----------- | --------------- | --------------- | --------------------------------------- |
+| `infra/apt` | `apt.moq.dev` | `apt-moq-dev` | Debian/Ubuntu package repository |
+| `infra/rpm` | `rpm.moq.dev` | `rpm-moq-dev` | Fedora/RHEL package repository |
+
+Each worker is a standalone bun package with a `wrangler.jsonc` and a
+small TypeScript handler that proxies GETs out of the R2 bucket with
+appropriate `Content-Type` and `Cache-Control` headers for the file kind.
+
+## Deploying a worker
+
+From the repo root:
+
+```bash
+just infra apt deploy
+just infra rpm deploy
+```
+
+Each recipe runs `bun install` and `bun wrangler deploy`. The
+`custom_domain: true` route entry in the wrangler config auto-provisions
+the DNS record on first deploy.
+
+## Bootstrapping a new package repository
+
+The CI workflows (`.github/workflows/apt-repo.yml`,
+`.github/workflows/rpm-repo.yml`) assume the R2 bucket, Cloudflare custom
+domain, and signing key already exist. To stand them up the first time:
+
+1. **Create the R2 buckets** in the existing Cloudflare account
+ (`dd618f5dbd5da77b8296f1613c301f5c`):
+
+ ```bash
+ bun wrangler r2 bucket create apt-moq-dev
+ bun wrangler r2 bucket create rpm-moq-dev
+ ```
+
+2. **Deploy the workers** so the custom domains come up:
+
+ ```bash
+ just infra deploy
+ ```
+
+3. **Reuse the project GPG signing key** that's already stored in the
+ `SIGNING_KEY` / `SIGNING_PASSWORD` Actions secrets (also used by the
+ Maven Central / Kotlin release workflow). The apt/rpm publish scripts
+ import the key into an ephemeral keyring and auto-detect the long key
+ id, so no separate `KEY_ID` secret is needed.
+
+4. **Upload the public key** to both buckets so users can verify the
+ repository signature:
+
+ ```bash
+ gpg --export --armor admin@moq.dev > moq-archive-keyring.gpg
+ bun wrangler r2 object put apt-moq-dev/moq-archive-keyring.gpg \
+ --file moq-archive-keyring.gpg --remote
+ bun wrangler r2 object put rpm-moq-dev/moq-archive-keyring.gpg \
+ --file moq-archive-keyring.gpg --remote
+ ```
+
+5. **Configure GitHub Actions secrets** (Settings -> Secrets and variables
+ -> Actions):
+
+ - `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY`: R2 API token with
+ object read/write on both buckets.
+ - `R2_ACCOUNT_ID`: the Cloudflare account id.
+ - `SIGNING_KEY` and `SIGNING_PASSWORD`: already configured for the
+ Maven Central release workflow; the apt/rpm publishers reuse them.
+
+After this, every release that publishes a `.deb` or `.rpm` (one of the
+`moq-relay-v*`, `moq-cli-v*`, `moq-token-cli-v*`, `moq-gst-v*` tags)
+triggers `apt-repo.yml` / `rpm-repo.yml`, which downloads the assets,
+regenerates the repository metadata, signs it, and uploads the diff.
+
+## Rotating the signing key
+
+If the key needs to be rotated, repeat steps 3 through 5 with a new key.
+Upload the new `moq-archive-keyring.gpg` alongside the old one (use a
+versioned filename, e.g. `moq-archive-keyring-2026.gpg`) and update the
+install docs at `doc/setup/linux.md` to point users at the new URL.
+Existing installations keep validating against the old key until they
+re-import.
+
+## Manual regeneration
+
+If a release was missed or the repository state needs to be rebuilt
+from scratch, the publish scripts can be invoked locally:
+
+```bash
+gh release download moq-relay-v1.2.3 --dir artifacts --pattern '*.deb'
+ARTIFACTS_DIR=artifacts \
+ R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... R2_ACCOUNT_ID=... \
+ SIGNING_KEY="$(cat private.asc)" SIGNING_PASSWORD=... \
+ ./infra/apt/publish.sh
+```
+
+Or trigger the GitHub Actions workflow manually:
+
+```bash
+gh workflow run apt-repo.yml -f tag=moq-relay-v1.2.3
+gh workflow run rpm-repo.yml -f tag=moq-relay-v1.2.3
+```
diff --git a/infra/apt/bun.lock b/infra/apt/bun.lock
new file mode 100644
index 0000000000..eef04294f3
--- /dev/null
+++ b/infra/apt/bun.lock
@@ -0,0 +1,204 @@
+{
+ "lockfileVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "apt-moq-dev",
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4",
+ "typescript": "^6",
+ "wrangler": "^4",
+ },
+ },
+ },
+ "packages": {
+ "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
+
+ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
+
+ "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260521.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-aiNdXmxlhwGjTSajL3I7uQPpN4lAOcXjvg5ZOlJKIywnevr798n9XCS6lvuqgniM3KjurBNWRRypMJntg/eSLg=="],
+
+ "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260521.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ikN8aKSi4Ak28ndOkuSO5rq6lmV6wwDQu9F9Vu6J7EkwAOth74J/Hjn4j4EuFceW/npw2Ws0Y/muzA6WKHl4TA=="],
+
+ "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260521.1", "", { "os": "linux", "cpu": "x64" }, "sha512-D/gUhvQcG0pJr5aJl6yUoi2JxbFpjVtDq9xUJHPjfkAjL28TUVgCR/e5r8YGirepv4I1DK7ihuii9LZ2GGMJbw=="],
+
+ "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260521.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-vhjWPIHenczegTakhRPwEmTeaavCpNqsuo3RlLCkUdU47HrwLvy/4QersGggs4+kF4Do+IE/EznCGyT40xYcLA=="],
+
+ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260521.1", "", { "os": "win32", "cpu": "x64" }, "sha512-wBolYC/+lnGIEbkkPdzFtjTOWip2uQH6maeAP1ZV0kyxi5SGpsa83+wD5rH5OOle+sHE5qJMdwCKjwRwj+FKJg=="],
+
+ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260523.1", "", {}, "sha512-Kr66Jip2K3t0srdoLLImzblTTx2T409Zk2J6AHANmlB950rlHr2henMQx7+cdQOLm2p1CttuBcYB1UVjdiFN8Q=="],
+
+ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
+
+ "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
+
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
+
+ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
+
+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
+
+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
+
+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
+
+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
+
+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
+
+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
+
+ "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
+
+ "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
+
+ "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
+
+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
+
+ "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
+
+ "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
+
+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
+
+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
+
+ "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
+
+ "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
+
+ "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
+
+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
+
+ "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
+
+ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
+
+ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
+
+ "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
+
+ "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
+
+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
+
+ "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
+
+ "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
+
+ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
+
+ "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
+
+ "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="],
+
+ "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
+
+ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
+
+ "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
+
+ "miniflare": ["miniflare@4.20260521.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260521.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-roRfxPq49OkuSeQsc43hRjSB1+HdHtDNKRwDEVk2hCjCBuBWxb5Wvwq88b0ULj6QVEJLN/+ZqF19M+h4VYJ/zg=="],
+
+ "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
+
+ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+
+ "rosie-skills": ["rosie-skills@0.6.4", "", { "optionalDependencies": { "rosie-skills-darwin-arm64": "0.6.4", "rosie-skills-freebsd-x64": "0.6.4", "rosie-skills-linux-x64": "0.6.4" }, "bin": { "rosie-skills": "dist/bin.js" } }, "sha512-ojfhSiQRdZ2QyWbmKAHOSAUbaLYrTc5zIH7mS1jKoP8KCFSQddwVhMyFqldckTeybTfW3zNcsZzyOTzGTN1SBA=="],
+
+ "rosie-skills-darwin-arm64": ["rosie-skills-darwin-arm64@0.6.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rn1s5hqFKcxeiDEWWoFa1hdGPshR8TkwHLzy/cBavb9XJNAaUxbe3oQ78W9sQkRHAgRyzJYyk9tw68Qrdnizgg=="],
+
+ "rosie-skills-freebsd-x64": ["rosie-skills-freebsd-x64@0.6.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SxCRduPBMtfjkQ+q56Yw9OLA3PyaqoALzt7kER7IDKuUVfM2O/1w8sa5xhTDiCvWkZJixnH5d5Ya6KT+/Mwcng=="],
+
+ "rosie-skills-linux-x64": ["rosie-skills-linux-x64@0.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-D9Y9mfu7goB0s0X59uU3hcFeUTef3VbpCIDwFMzyvJrAq3XhRACWBDMHQsHlyWdHxTXPX/ILyW65RXyrJlgqng=="],
+
+ "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
+
+ "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
+
+ "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
+
+ "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="],
+
+ "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
+
+ "workerd": ["workerd@1.20260521.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260521.1", "@cloudflare/workerd-darwin-arm64": "1.20260521.1", "@cloudflare/workerd-linux-64": "1.20260521.1", "@cloudflare/workerd-linux-arm64": "1.20260521.1", "@cloudflare/workerd-windows-64": "1.20260521.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-HzIThcZ0ZVEuzVxpY2IYZ3yssSrTjtrWXAVfmOl5rVwyqcu7aeZXGMiwrEmi9MOcC3wjy+BNv+hFrMMY5OrjQQ=="],
+
+ "wrangler": ["wrangler@4.94.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260521.0", "path-to-regexp": "6.3.0", "rosie-skills": "^0.6.3", "unenv": "2.0.0-rc.24", "workerd": "1.20260521.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260521.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-GsNw0DomGFfeXFtKVTwn2X69UKcCxcTB0CXykjsMineJIxOeyrw7LovlHQ/3JU8KJHH7repLB+kOHvfTBA/Eew=="],
+
+ "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
+
+ "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
+
+ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
+ }
+}
diff --git a/infra/apt/justfile b/infra/apt/justfile
new file mode 100644
index 0000000000..bfec5d4051
--- /dev/null
+++ b/infra/apt/justfile
@@ -0,0 +1,16 @@
+set fallback
+
+# Deploy the apt.moq.dev worker.
+deploy:
+ bun install
+ bun wrangler deploy
+
+# Regenerate apt repo metadata and upload to the apt-moq-dev R2 bucket.
+# Reads .deb files from $ARTIFACTS_DIR (defaults to ./artifacts).
+publish artifacts="artifacts":
+ ARTIFACTS_DIR="{{ artifacts }}" ./publish.sh
+
+# List objects currently in the apt-moq-dev bucket.
+list:
+ bun install
+ bun wrangler r2 object list "apt-moq-dev"
diff --git a/infra/apt/package.json b/infra/apt/package.json
new file mode 100644
index 0000000000..d83e56908b
--- /dev/null
+++ b/infra/apt/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "apt-moq-dev",
+ "private": true,
+ "scripts": {
+ "deploy": "wrangler deploy"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4",
+ "typescript": "^6",
+ "wrangler": "^4"
+ }
+}
diff --git a/infra/apt/publish.sh b/infra/apt/publish.sh
new file mode 100755
index 0000000000..be08cb2dec
--- /dev/null
+++ b/infra/apt/publish.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+#
+# Regenerate apt repo metadata and push to the apt-moq-dev R2 bucket.
+# Pull the current pool, merge in new .deb files from $ARTIFACTS_DIR,
+# rebuild dists/stable metadata with apt-ftparchive, sign with GPG, and
+# upload only what changed.
+#
+# Required env:
+# ARTIFACTS_DIR directory containing new .deb files to add
+# R2_ACCESS_KEY_ID R2 API token
+# R2_SECRET_ACCESS_KEY
+# R2_ACCOUNT_ID
+# SIGNING_KEY ascii-armored GPG private key (shared with maven publishing)
+# SIGNING_PASSWORD optional passphrase for SIGNING_KEY
+#
+# Required tools: rclone, apt-ftparchive (apt-utils), gpg, dpkg-scanpackages.
+
+set -euo pipefail
+
+ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts}"
+BUCKET="apt-moq-dev"
+DIST="stable"
+COMPONENT="main"
+ORIGIN="MoQ Project"
+LABEL="moq"
+SUITE="$DIST"
+DESCRIPTION="Media over QUIC apt repository"
+ARCHES=(amd64 arm64)
+
+# Make rclone talk to R2. R2 is S3-compatible.
+export RCLONE_CONFIG_R2_TYPE=s3
+export RCLONE_CONFIG_R2_PROVIDER=Cloudflare
+export RCLONE_CONFIG_R2_ENDPOINT="https://${R2_ACCOUNT_ID:?}.r2.cloudflarestorage.com"
+export RCLONE_CONFIG_R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:?}"
+export RCLONE_CONFIG_R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:?}"
+export RCLONE_CONFIG_R2_ACL=private
+
+WORK=$(mktemp -d)
+GNUPGHOME=""
+cleanup() {
+ rm -rf "$WORK"
+ [[ -n "$GNUPGHOME" ]] && rm -rf "$GNUPGHOME"
+}
+trap cleanup EXIT
+
+# Pull additively: a partial fetch must never cause subsequent steps to act
+# on an incomplete view of the pool, which would drop the missing packages
+# from the regenerated Packages indexes.
+echo ">> Pull current pool from R2..."
+mkdir -p "$WORK/pool"
+rclone copy "r2:${BUCKET}/pool" "$WORK/pool" --quiet
+
+echo ">> Add new .deb files to pool..."
+shopt -s nullglob
+new_debs=("$ARTIFACTS_DIR"/*.deb)
+if [[ ${#new_debs[@]} -eq 0 ]]; then
+ echo "No .deb files in $ARTIFACTS_DIR; nothing to do." >&2
+ exit 0
+fi
+for deb in "${new_debs[@]}"; do
+ pkg=$(dpkg-deb -f "$deb" Package)
+ dest="$WORK/pool/main/${pkg:0:1}/${pkg}"
+ mkdir -p "$dest"
+ cp "$deb" "$dest/"
+done
+
+echo ">> Generate Packages indexes per arch..."
+mkdir -p "$WORK/dists/$DIST/$COMPONENT"
+for arch in "${ARCHES[@]}"; do
+ out="$WORK/dists/$DIST/$COMPONENT/binary-${arch}"
+ mkdir -p "$out"
+ (cd "$WORK" && apt-ftparchive --arch "$arch" packages "pool/$COMPONENT") \
+ >"$out/Packages"
+ gzip -9kf "$out/Packages"
+done
+
+echo ">> Generate Release..."
+cat >"$WORK/apt-ftparchive.conf" <"$WORK/dists/$DIST/Release"
+
+echo ">> Sign Release..."
+GNUPGHOME=$(mktemp -d)
+export GNUPGHOME
+chmod 700 "$GNUPGHOME"
+# GNUPGHOME is removed by the EXIT trap; no need for an explicit `rm -rf`.
+echo "${SIGNING_KEY:?}" | gpg --batch --quiet --import
+# Fail loud if SIGNING_KEY ever holds more than one secret. Silently picking
+# the first one would produce signatures from the wrong key.
+mapfile -t KEY_IDS < <(gpg --list-secret-keys --with-colons --keyid-format=long |
+ awk -F: '/^sec:/ { print $5 }')
+if [[ ${#KEY_IDS[@]} -ne 1 ]]; then
+ echo "ERROR: expected exactly one secret key in SIGNING_KEY, found ${#KEY_IDS[@]}." >&2
+ exit 1
+fi
+KEY_ID="${KEY_IDS[0]}"
+GPG_PASS_ARGS=()
+if [[ -n "${SIGNING_PASSWORD:-}" ]]; then
+ GPG_PASS_ARGS=(--pinentry-mode loopback --passphrase "$SIGNING_PASSWORD")
+fi
+gpg --batch --yes "${GPG_PASS_ARGS[@]}" --default-key "$KEY_ID" --detach-sign --armor \
+ -o "$WORK/dists/$DIST/Release.gpg" \
+ "$WORK/dists/$DIST/Release"
+gpg --batch --yes "${GPG_PASS_ARGS[@]}" --default-key "$KEY_ID" --clearsign \
+ -o "$WORK/dists/$DIST/InRelease" \
+ "$WORK/dists/$DIST/Release"
+
+echo ">> Upload pool additions..."
+rclone copy "$WORK/pool" "r2:${BUCKET}/pool" --quiet
+
+echo ">> Upload regenerated dists..."
+rclone sync "$WORK/dists" "r2:${BUCKET}/dists" --quiet
+
+echo ">> Done. Repo updated at https://apt.moq.dev/dists/$DIST/"
diff --git a/infra/apt/src/worker.ts b/infra/apt/src/worker.ts
new file mode 100644
index 0000000000..3c670a06a8
--- /dev/null
+++ b/infra/apt/src/worker.ts
@@ -0,0 +1,86 @@
+// Cloudflare Worker for apt.moq.dev. Serves a flat-ish apt repository out of
+// the apt-moq-dev R2 bucket. Layout written by infra/apt/publish.sh:
+//
+// /moq-archive-keyring.gpg public signing key
+// /dists/stable/InRelease signed metadata
+// /dists/stable/Release metadata
+// /dists/stable/Release.gpg detached signature
+// /dists/stable/main/binary-{amd64,arm64}/Packages{,.gz}
+// /pool/main//__.deb actual packages
+
+interface Env {
+ APT: R2Bucket;
+}
+
+// Content-Type mapping. apt is picky about Release/InRelease being text/plain
+// and .deb being the official MIME type, otherwise some proxies mangle them.
+const CONTENT_TYPES: Record = {
+ ".deb": "application/vnd.debian.binary-package",
+ ".gpg": "application/pgp-signature",
+ ".gz": "application/gzip",
+ ".xz": "application/x-xz",
+ ".bz2": "application/x-bzip2",
+};
+
+// Exact filenames whose Content-Type isn't determined by extension.
+const CONTENT_TYPE_BY_NAME: Record = {
+ Release: "text/plain; charset=utf-8",
+ InRelease: "text/plain; charset=utf-8",
+ Packages: "text/plain; charset=utf-8",
+ Sources: "text/plain; charset=utf-8",
+};
+
+// Cache long for the content-addressed package blobs and the static archive
+// keyring (versioned filename or pinned, immutable). Cache short for repo
+// metadata signatures like Release.gpg, which get rewritten every release.
+function cacheControl(key: string): string {
+ if (key.endsWith(".deb") || key.includes("/pool/") || key === "moq-archive-keyring.gpg") {
+ return "public, max-age=2592000, immutable";
+ }
+ return "public, max-age=300";
+}
+
+function contentType(key: string): string {
+ const base = key.substring(key.lastIndexOf("/") + 1);
+ if (base in CONTENT_TYPE_BY_NAME) {
+ return CONTENT_TYPE_BY_NAME[base];
+ }
+ const dotIdx = base.lastIndexOf(".");
+ const ext = dotIdx >= 0 ? base.substring(dotIdx).toLowerCase() : "";
+ return CONTENT_TYPES[ext] ?? "application/octet-stream";
+}
+
+export default {
+ async fetch(request: Request, env: Env): Promise {
+ if (request.method !== "GET" && request.method !== "HEAD") {
+ return new Response("Method Not Allowed", { status: 405 });
+ }
+
+ const url = new URL(request.url);
+ const key = url.pathname.slice(1);
+
+ if (!key) {
+ return new Response("MoQ apt repository. See https://moq.dev/install/linux for usage.\n", {
+ status: 200,
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+ }
+
+ const object = await env.APT.get(key);
+ if (!object) {
+ return new Response("Not Found", { status: 404 });
+ }
+
+ const headers = {
+ "Content-Type": contentType(key),
+ "Cache-Control": cacheControl(key),
+ "Content-Length": object.size.toString(),
+ };
+
+ if (request.method === "HEAD") {
+ return new Response(null, { headers });
+ }
+
+ return new Response(object.body, { headers });
+ },
+};
diff --git a/infra/apt/wrangler.jsonc b/infra/apt/wrangler.jsonc
new file mode 100644
index 0000000000..37eb150d32
--- /dev/null
+++ b/infra/apt/wrangler.jsonc
@@ -0,0 +1,28 @@
+{
+ // Cloudflare Wrangler configuration for the apt repository worker.
+ // Serves apt.moq.dev, backed by the apt-moq-dev R2 bucket.
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "apt-moq-dev",
+ "main": "src/worker.ts",
+ "compatibility_date": "2025-07-09",
+ "account_id": "dd618f5dbd5da77b8296f1613c301f5c",
+ "workers_dev": false,
+ "route": {
+ "pattern": "apt.moq.dev",
+ "custom_domain": true
+ },
+ // R2 bucket bindings
+ "r2_buckets": [
+ {
+ "binding": "APT",
+ "bucket_name": "apt-moq-dev",
+ "remote": true
+ }
+ ],
+ // Observability
+ "observability": {
+ "logs": {
+ "enabled": true
+ }
+ }
+}
diff --git a/infra/justfile b/infra/justfile
new file mode 100644
index 0000000000..5edcd8ec81
--- /dev/null
+++ b/infra/justfile
@@ -0,0 +1,9 @@
+set fallback
+
+mod apt
+mod rpm
+
+# Deploy all infra workers.
+deploy:
+ just apt deploy
+ just rpm deploy
diff --git a/infra/rpm/bun.lock b/infra/rpm/bun.lock
new file mode 100644
index 0000000000..0341e56b0a
--- /dev/null
+++ b/infra/rpm/bun.lock
@@ -0,0 +1,204 @@
+{
+ "lockfileVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "rpm-moq-dev",
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4",
+ "typescript": "^6",
+ "wrangler": "^4",
+ },
+ },
+ },
+ "packages": {
+ "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
+
+ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
+
+ "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260521.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-aiNdXmxlhwGjTSajL3I7uQPpN4lAOcXjvg5ZOlJKIywnevr798n9XCS6lvuqgniM3KjurBNWRRypMJntg/eSLg=="],
+
+ "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260521.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ikN8aKSi4Ak28ndOkuSO5rq6lmV6wwDQu9F9Vu6J7EkwAOth74J/Hjn4j4EuFceW/npw2Ws0Y/muzA6WKHl4TA=="],
+
+ "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260521.1", "", { "os": "linux", "cpu": "x64" }, "sha512-D/gUhvQcG0pJr5aJl6yUoi2JxbFpjVtDq9xUJHPjfkAjL28TUVgCR/e5r8YGirepv4I1DK7ihuii9LZ2GGMJbw=="],
+
+ "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260521.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-vhjWPIHenczegTakhRPwEmTeaavCpNqsuo3RlLCkUdU47HrwLvy/4QersGggs4+kF4Do+IE/EznCGyT40xYcLA=="],
+
+ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260521.1", "", { "os": "win32", "cpu": "x64" }, "sha512-wBolYC/+lnGIEbkkPdzFtjTOWip2uQH6maeAP1ZV0kyxi5SGpsa83+wD5rH5OOle+sHE5qJMdwCKjwRwj+FKJg=="],
+
+ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260523.1", "", {}, "sha512-Kr66Jip2K3t0srdoLLImzblTTx2T409Zk2J6AHANmlB950rlHr2henMQx7+cdQOLm2p1CttuBcYB1UVjdiFN8Q=="],
+
+ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
+
+ "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
+
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
+
+ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
+
+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
+
+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
+
+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
+
+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
+
+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
+
+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
+
+ "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
+
+ "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
+
+ "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
+
+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
+
+ "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
+
+ "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
+
+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
+
+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
+
+ "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
+
+ "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
+
+ "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
+
+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
+
+ "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
+
+ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
+
+ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
+
+ "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
+
+ "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
+
+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
+
+ "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
+
+ "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
+
+ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
+
+ "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
+
+ "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="],
+
+ "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
+
+ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
+
+ "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
+
+ "miniflare": ["miniflare@4.20260521.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260521.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-roRfxPq49OkuSeQsc43hRjSB1+HdHtDNKRwDEVk2hCjCBuBWxb5Wvwq88b0ULj6QVEJLN/+ZqF19M+h4VYJ/zg=="],
+
+ "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
+
+ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+
+ "rosie-skills": ["rosie-skills@0.6.4", "", { "optionalDependencies": { "rosie-skills-darwin-arm64": "0.6.4", "rosie-skills-freebsd-x64": "0.6.4", "rosie-skills-linux-x64": "0.6.4" }, "bin": { "rosie-skills": "dist/bin.js" } }, "sha512-ojfhSiQRdZ2QyWbmKAHOSAUbaLYrTc5zIH7mS1jKoP8KCFSQddwVhMyFqldckTeybTfW3zNcsZzyOTzGTN1SBA=="],
+
+ "rosie-skills-darwin-arm64": ["rosie-skills-darwin-arm64@0.6.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rn1s5hqFKcxeiDEWWoFa1hdGPshR8TkwHLzy/cBavb9XJNAaUxbe3oQ78W9sQkRHAgRyzJYyk9tw68Qrdnizgg=="],
+
+ "rosie-skills-freebsd-x64": ["rosie-skills-freebsd-x64@0.6.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SxCRduPBMtfjkQ+q56Yw9OLA3PyaqoALzt7kER7IDKuUVfM2O/1w8sa5xhTDiCvWkZJixnH5d5Ya6KT+/Mwcng=="],
+
+ "rosie-skills-linux-x64": ["rosie-skills-linux-x64@0.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-D9Y9mfu7goB0s0X59uU3hcFeUTef3VbpCIDwFMzyvJrAq3XhRACWBDMHQsHlyWdHxTXPX/ILyW65RXyrJlgqng=="],
+
+ "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
+
+ "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
+
+ "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
+
+ "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="],
+
+ "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
+
+ "workerd": ["workerd@1.20260521.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260521.1", "@cloudflare/workerd-darwin-arm64": "1.20260521.1", "@cloudflare/workerd-linux-64": "1.20260521.1", "@cloudflare/workerd-linux-arm64": "1.20260521.1", "@cloudflare/workerd-windows-64": "1.20260521.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-HzIThcZ0ZVEuzVxpY2IYZ3yssSrTjtrWXAVfmOl5rVwyqcu7aeZXGMiwrEmi9MOcC3wjy+BNv+hFrMMY5OrjQQ=="],
+
+ "wrangler": ["wrangler@4.94.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260521.0", "path-to-regexp": "6.3.0", "rosie-skills": "^0.6.3", "unenv": "2.0.0-rc.24", "workerd": "1.20260521.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260521.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-GsNw0DomGFfeXFtKVTwn2X69UKcCxcTB0CXykjsMineJIxOeyrw7LovlHQ/3JU8KJHH7repLB+kOHvfTBA/Eew=="],
+
+ "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
+
+ "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
+
+ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
+ }
+}
diff --git a/infra/rpm/justfile b/infra/rpm/justfile
new file mode 100644
index 0000000000..2c6b1be9b3
--- /dev/null
+++ b/infra/rpm/justfile
@@ -0,0 +1,16 @@
+set fallback
+
+# Deploy the rpm.moq.dev worker.
+deploy:
+ bun install
+ bun wrangler deploy
+
+# Regenerate rpm repo metadata and upload to the rpm-moq-dev R2 bucket.
+# Reads .rpm files from $ARTIFACTS_DIR (defaults to ./artifacts).
+publish artifacts="artifacts":
+ ARTIFACTS_DIR="{{ artifacts }}" ./publish.sh
+
+# List objects currently in the rpm-moq-dev bucket.
+list:
+ bun install
+ bun wrangler r2 object list "rpm-moq-dev"
diff --git a/infra/rpm/package.json b/infra/rpm/package.json
new file mode 100644
index 0000000000..6065098d24
--- /dev/null
+++ b/infra/rpm/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "rpm-moq-dev",
+ "private": true,
+ "scripts": {
+ "deploy": "wrangler deploy"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4",
+ "typescript": "^6",
+ "wrangler": "^4"
+ }
+}
diff --git a/infra/rpm/publish.sh b/infra/rpm/publish.sh
new file mode 100755
index 0000000000..c1004317ca
--- /dev/null
+++ b/infra/rpm/publish.sh
@@ -0,0 +1,128 @@
+#!/usr/bin/env bash
+#
+# Regenerate yum/dnf repo metadata and push to the rpm-moq-dev R2 bucket.
+# Pull the current pool, merge in new .rpm files from $ARTIFACTS_DIR,
+# rebuild repodata with createrepo_c, sign repomd.xml with GPG, and upload.
+#
+# Required env:
+# ARTIFACTS_DIR directory containing new .rpm files to add
+# R2_ACCESS_KEY_ID R2 API token
+# R2_SECRET_ACCESS_KEY
+# R2_ACCOUNT_ID
+# SIGNING_KEY ascii-armored GPG private key (shared with apt repo and maven publishing)
+# SIGNING_PASSWORD optional passphrase for SIGNING_KEY
+#
+# Required tools: rclone, createrepo_c, gpg.
+
+set -euo pipefail
+
+ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts}"
+BUCKET="rpm-moq-dev"
+DIST="el9"
+ARCHES=(x86_64 aarch64)
+
+# Make rclone talk to R2.
+export RCLONE_CONFIG_R2_TYPE=s3
+export RCLONE_CONFIG_R2_PROVIDER=Cloudflare
+export RCLONE_CONFIG_R2_ENDPOINT="https://${R2_ACCOUNT_ID:?}.r2.cloudflarestorage.com"
+export RCLONE_CONFIG_R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:?}"
+export RCLONE_CONFIG_R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:?}"
+export RCLONE_CONFIG_R2_ACL=private
+# The R2 token has object read/write but not CreateBucket. rclone normally
+# probes the bucket on writes to a bucket-root key (e.g. moq.repo), which
+# surfaces as a 403 AccessDenied. Skip the probe; the bucket already exists.
+export RCLONE_CONFIG_R2_NO_CHECK_BUCKET=true
+
+WORK=$(mktemp -d)
+GNUPGHOME=""
+cleanup() {
+ rm -rf "$WORK"
+ [[ -n "$GNUPGHOME" ]] && rm -rf "$GNUPGHOME"
+}
+trap cleanup EXIT
+
+# Pull additively: a partial fetch must never cause the push step to delete
+# remote .rpms. createrepo_c --update overwrites repodata in place, so a
+# stale local repodata is fine - the regenerate step rewrites it.
+echo ">> Pull current repo from R2..."
+mkdir -p "$WORK/${DIST}"
+rclone copy "r2:${BUCKET}/${DIST}" "$WORK/${DIST}" --quiet
+
+echo ">> Sort new .rpm files by arch..."
+shopt -s nullglob
+new_rpms=("$ARTIFACTS_DIR"/*.rpm)
+if [[ ${#new_rpms[@]} -eq 0 ]]; then
+ echo "No .rpm files in $ARTIFACTS_DIR; nothing to do." >&2
+ exit 0
+fi
+for rpm in "${new_rpms[@]}"; do
+ arch=$(rpm -qp --queryformat '%{ARCH}' "$rpm")
+ # Map noarch packages into every supported per-arch tree.
+ if [[ "$arch" == "noarch" ]]; then
+ for a in "${ARCHES[@]}"; do
+ mkdir -p "$WORK/${DIST}/${a}"
+ cp "$rpm" "$WORK/${DIST}/${a}/"
+ done
+ elif [[ " ${ARCHES[*]} " == *" ${arch} "* ]]; then
+ mkdir -p "$WORK/${DIST}/${arch}"
+ cp "$rpm" "$WORK/${DIST}/${arch}/"
+ else
+ echo "ERROR: ${rpm} has unsupported arch '${arch}'; expected one of: ${ARCHES[*]} or noarch." >&2
+ exit 1
+ fi
+done
+
+echo ">> Import signing key..."
+GNUPGHOME=$(mktemp -d)
+export GNUPGHOME
+chmod 700 "$GNUPGHOME"
+echo "${SIGNING_KEY:?}" | gpg --batch --quiet --import
+# Fail loud if SIGNING_KEY ever holds more than one secret. Silently picking
+# the first one would produce signatures from the wrong key.
+mapfile -t KEY_IDS < <(gpg --list-secret-keys --with-colons --keyid-format=long |
+ awk -F: '/^sec:/ { print $5 }')
+if [[ ${#KEY_IDS[@]} -ne 1 ]]; then
+ echo "ERROR: expected exactly one secret key in SIGNING_KEY, found ${#KEY_IDS[@]}." >&2
+ exit 1
+fi
+KEY_ID="${KEY_IDS[0]}"
+GPG_PASS_ARGS=()
+if [[ -n "${SIGNING_PASSWORD:-}" ]]; then
+ GPG_PASS_ARGS=(--pinentry-mode loopback --passphrase "$SIGNING_PASSWORD")
+fi
+
+echo ">> Generate repodata per arch..."
+for arch in "${ARCHES[@]}"; do
+ dir="$WORK/${DIST}/${arch}"
+ [[ -d "$dir" ]] || continue
+ createrepo_c --update --general-compress-type=gz "$dir"
+ gpg --batch --yes "${GPG_PASS_ARGS[@]}" --default-key "$KEY_ID" --detach-sign --armor \
+ -o "$dir/repodata/repomd.xml.asc" \
+ "$dir/repodata/repomd.xml"
+done
+
+echo ">> Write moq.repo template..."
+cat >"$WORK/moq.repo" <> Upload to R2..."
+for arch in "${ARCHES[@]}"; do
+ dir="$WORK/${DIST}/${arch}"
+ [[ -d "$dir" ]] || continue
+ rclone copy "$dir" "r2:${BUCKET}/${DIST}/${arch}" --include "*.rpm" --quiet
+ rclone sync "$dir/repodata" "r2:${BUCKET}/${DIST}/${arch}/repodata" --quiet
+done
+rclone copyto "$WORK/moq.repo" "r2:${BUCKET}/moq.repo" --quiet
+
+echo ">> Done. Repo updated at https://rpm.moq.dev/${DIST}/"
diff --git a/infra/rpm/src/worker.ts b/infra/rpm/src/worker.ts
new file mode 100644
index 0000000000..97409a8d15
--- /dev/null
+++ b/infra/rpm/src/worker.ts
@@ -0,0 +1,80 @@
+// Cloudflare Worker for rpm.moq.dev. Serves a yum/dnf repository out of the
+// rpm-moq-dev R2 bucket. Layout written by infra/rpm/publish.sh:
+//
+// /moq-archive-keyring.gpg public signing key
+// /moq.repo .repo file users drop into /etc/yum.repos.d/
+// /el9/x86_64/repodata/repomd.xml{,.asc} signed metadata
+// /el9/x86_64/repodata/-primary.xml.gz indices
+// /el9/x86_64/--1..rpm actual packages
+
+interface Env {
+ RPM: R2Bucket;
+}
+
+const CONTENT_TYPES: Record = {
+ ".rpm": "application/x-rpm",
+ ".xml": "application/xml",
+ ".gz": "application/gzip",
+ ".xz": "application/x-xz",
+ ".bz2": "application/x-bzip2",
+ ".gpg": "application/pgp-signature",
+ ".asc": "application/pgp-signature",
+ ".repo": "text/plain; charset=utf-8",
+};
+
+const CONTENT_TYPE_BY_NAME: Record = {
+ "repomd.xml": "application/xml",
+};
+
+// Repo metadata changes per release; .rpm blobs are versioned and immutable.
+function cacheControl(key: string): string {
+ if (key.endsWith(".rpm") || key.endsWith(".gpg")) {
+ return "public, max-age=2592000, immutable";
+ }
+ return "public, max-age=300";
+}
+
+function contentType(key: string): string {
+ const base = key.substring(key.lastIndexOf("/") + 1);
+ if (base in CONTENT_TYPE_BY_NAME) {
+ return CONTENT_TYPE_BY_NAME[base];
+ }
+ const dotIdx = base.lastIndexOf(".");
+ const ext = dotIdx >= 0 ? base.substring(dotIdx).toLowerCase() : "";
+ return CONTENT_TYPES[ext] ?? "application/octet-stream";
+}
+
+export default {
+ async fetch(request: Request, env: Env): Promise {
+ if (request.method !== "GET" && request.method !== "HEAD") {
+ return new Response("Method Not Allowed", { status: 405 });
+ }
+
+ const url = new URL(request.url);
+ const key = url.pathname.slice(1);
+
+ if (!key) {
+ return new Response("MoQ rpm repository. See https://moq.dev/install/linux for usage.\n", {
+ status: 200,
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+ }
+
+ const object = await env.RPM.get(key);
+ if (!object) {
+ return new Response("Not Found", { status: 404 });
+ }
+
+ const headers = {
+ "Content-Type": contentType(key),
+ "Cache-Control": cacheControl(key),
+ "Content-Length": object.size.toString(),
+ };
+
+ if (request.method === "HEAD") {
+ return new Response(null, { headers });
+ }
+
+ return new Response(object.body, { headers });
+ },
+};
diff --git a/infra/rpm/wrangler.jsonc b/infra/rpm/wrangler.jsonc
new file mode 100644
index 0000000000..184981eada
--- /dev/null
+++ b/infra/rpm/wrangler.jsonc
@@ -0,0 +1,28 @@
+{
+ // Cloudflare Wrangler configuration for the rpm repository worker.
+ // Serves rpm.moq.dev, backed by the rpm-moq-dev R2 bucket.
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "rpm-moq-dev",
+ "main": "src/worker.ts",
+ "compatibility_date": "2025-07-09",
+ "account_id": "dd618f5dbd5da77b8296f1613c301f5c",
+ "workers_dev": false,
+ "route": {
+ "pattern": "rpm.moq.dev",
+ "custom_domain": true
+ },
+ // R2 bucket bindings
+ "r2_buckets": [
+ {
+ "binding": "RPM",
+ "bucket_name": "rpm-moq-dev",
+ "remote": true
+ }
+ ],
+ // Observability
+ "observability": {
+ "logs": {
+ "enabled": true
+ }
+ }
+}
diff --git a/js/clock/README.md b/js/clock/README.md
index 2ea83df986..aa54fd2386 100644
--- a/js/clock/README.md
+++ b/js/clock/README.md
@@ -3,7 +3,7 @@
A TypeScript implementation of a simple clock protocol over MoQ.
This has no practical value; it's just an example of how to support non-media payloads.
-Currently, this only works in the browser or when run using [Deno](https://deno.land).
+This works in the browser or when run using [Bun](https://bun.sh) with a WebTransport polyfill.
In theory it should work for any Javascript runtime that supports WebTransport.
## Usage
@@ -12,21 +12,20 @@ The TypeScript implementation mirrors the Rust CLI interface:
```bash
# Publish a clock broadcast
-./src/main.ts --url https://cdn.moq.dev/anon --broadcast myclock publish
+bun ./src/main.ts --url https://cdn.moq.dev/anon --broadcast myclock publish
# Subscribe to a clock broadcast
-./src/main.ts --url https://cdn.moq.dev/anon --broadcast myclock subscribe
+bun ./src/main.ts --url https://cdn.moq.dev/anon --broadcast myclock subscribe
```
If you're running a relay server locally, use `http://localhost:4443/anon` instead.
## Wire Format
-The wire format is identical to the Rust `moq-clock` crate:
+The wire format is identical to the Rust [clock example](https://github.com/moq-dev/moq/blob/main/rs/moq-native/examples/clock.rs):
1. **Groups**: Each group represents one minute of data
2. **Base Frame**: First frame contains the timestamp base (e.g., "2025-01-31 14:23:")
3. **Second Frames**: Subsequent frames contain individual seconds (e.g., "00", "01", "02", ...)
It's a crude format, but it shows how delta encoding can work.
-
diff --git a/js/clock/deno.json b/js/clock/deno.json
deleted file mode 100644
index ed89e38c98..0000000000
--- a/js/clock/deno.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "compilerOptions": {
- "lib": ["esnext", "dom", "deno.ns"],
- "strict": true
- }
-}
diff --git a/js/clock/package.json b/js/clock/package.json
index 8ccdc535de..8bbc2e39b9 100644
--- a/js/clock/package.json
+++ b/js/clock/package.json
@@ -5,6 +5,7 @@
"description": "MoQ clock example with publish/subscribe functionality",
"license": "(MIT OR Apache-2.0)",
"repository": "github:moq-dev/moq",
+ "private": true,
"files": [
"./src",
"README.md"
@@ -13,13 +14,15 @@
"moq-clock": "./src/main.ts"
},
"scripts": {
- "check": "deno check"
+ "check": "tsc --noEmit"
},
"dependencies": {
- "@moq/lite": "workspace:*"
+ "@moq/net": "workspace:*",
+ "@fails-components/webtransport": "^1.5.2",
+ "@fails-components/webtransport-transport-http3-quiche": "^1.5.2"
},
"devDependencies": {
- "@types/deno": "^2.3.0",
- "typescript": "^5.9.2"
+ "@types/node": "^24.3.1",
+ "typescript": "^6.0.0"
}
}
diff --git a/js/clock/src/main.ts b/js/clock/src/main.ts
index e13db2af5c..7c1906a983 100755
--- a/js/clock/src/main.ts
+++ b/js/clock/src/main.ts
@@ -1,9 +1,11 @@
-#!/usr/bin/env -S deno run --allow-net --allow-env --unstable-net --unstable-sloppy-imports
+import { quicheLoaded, WebTransport } from "@fails-components/webtransport";
+import { parseArgs } from "util";
-// @ts-ignore Deno import.
-import { parseArgs } from "jsr:@std/cli/parse-args";
+// Polyfill WebTransport for Bun/Node environments
+// @ts-ignore - assigning to globalThis
+globalThis.WebTransport = WebTransport;
-import * as Moq from "@moq/lite";
+import * as Moq from "@moq/net";
interface Config {
url: string;
@@ -13,20 +15,21 @@ interface Config {
}
function parseConfig(): Config {
- const args = parseArgs(Deno.args, {
- string: ["url", "broadcast", "track"],
- boolean: ["help"],
- default: {
- track: "seconds",
- },
- alias: {
- h: "help",
+ const { values, positionals } = parseArgs({
+ args: process.argv.slice(2),
+ options: {
+ url: { type: "string" },
+ broadcast: { type: "string" },
+ track: { type: "string", default: "seconds" },
+ help: { type: "boolean", short: "h" },
},
+ allowPositionals: true,
});
- if (args.help) {
+ if (values.help) {
console.log(`
-Usage: ./main.ts [OPTIONS]
+Usage: bun run main.ts [OPTIONS]
+ or: npx tsx main.ts [OPTIONS]
OPTIONS:
--url Connect to the given URL starting with https://
@@ -42,28 +45,28 @@ ENVIRONMENT VARIABLES:
MOQ_URL Default URL to connect to
MOQ_NAME Default broadcast name
`);
- Deno.exit(0);
+ process.exit(0);
}
- const role = args._[0] as string;
+ const role = positionals[0];
if (!role || (role !== "publish" && role !== "subscribe")) {
console.error("Error: Must specify 'publish' or 'subscribe' command");
- Deno.exit(1);
+ process.exit(1);
}
- const url = args.url || Deno.env.get("MOQ_URL");
- const broadcast = args.broadcast || Deno.env.get("MOQ_NAME");
+ const url = values.url || process.env.MOQ_URL;
+ const broadcast = values.broadcast || process.env.MOQ_NAME;
if (!url || !broadcast) {
console.error("Error: --url and --broadcast are required");
console.error("Provide them as arguments or set MOQ_URL and MOQ_NAME environment variables");
- Deno.exit(1);
+ process.exit(1);
}
return {
url,
broadcast,
- track: args.track,
+ track: values.track,
role: role as "publish" | "subscribe",
};
}
@@ -144,7 +147,7 @@ async function subscribe(config: Config) {
// Handle groups and frames like the Rust implementation
for (;;) {
- const group = await track.nextGroup();
+ const group = await track.recvGroup();
if (!group) {
console.log("❌ Connection ended");
break;
@@ -180,6 +183,9 @@ async function subscribe(config: Config) {
}
}
+// Wait for the WebTransport polyfill to be ready
+await quicheLoaded;
+
try {
const config = parseConfig();
@@ -190,5 +196,5 @@ try {
}
} catch (error) {
console.error("❌ Error:", error);
- Deno.exit(1);
+ process.exit(1);
}
diff --git a/js/clock/src/webtransport.d.ts b/js/clock/src/webtransport.d.ts
new file mode 100644
index 0000000000..f8641124fd
--- /dev/null
+++ b/js/clock/src/webtransport.d.ts
@@ -0,0 +1,7 @@
+// Type declarations for @fails-components/webtransport
+// This package provides WebTransport polyfill for Node.js/Bun environments.
+
+declare module "@fails-components/webtransport" {
+ export const WebTransport: typeof globalThis.WebTransport;
+ export const quicheLoaded: Promise;
+}
diff --git a/js/clock/tsconfig.json b/js/clock/tsconfig.json
index dafeeda7b2..1474d76032 100644
--- a/js/clock/tsconfig.json
+++ b/js/clock/tsconfig.json
@@ -2,7 +2,8 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
- "outDir": "./dist"
+ "outDir": "./dist",
+ "types": ["node"]
},
"include": ["src"]
}
diff --git a/js/common/package.ts b/js/common/package.ts
new file mode 100644
index 0000000000..8ef80e9c1c
--- /dev/null
+++ b/js/common/package.ts
@@ -0,0 +1,108 @@
+// Script to build and package a workspace for distribution
+// This creates a dist/ folder with the correct paths and dependencies for publishing
+// Split from release.ts to allow building packages without publishing
+
+import { copyFileSync, readFileSync, writeFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { publint } from "publint";
+import { formatMessage } from "publint/utils";
+
+console.log("✍️ Rewriting package.json...");
+const pkg = JSON.parse(readFileSync("package.json", "utf8"));
+
+function rewritePath(p: string, ext: string): string {
+ return p.replace(/^\.\/src/, ".").replace(/\.ts(x)?$/, `.${ext}`);
+}
+
+pkg.main &&= rewritePath(pkg.main, "js");
+pkg.types &&= rewritePath(pkg.types, "d.ts");
+
+if (pkg.exports) {
+ for (const key in pkg.exports) {
+ const val = pkg.exports[key];
+ if (typeof val === "string") {
+ if (val.endsWith(".css")) {
+ // CSS exports are only needed for dev-time resolution;
+ // consumers inline them at build time via @import.
+ // We purposely do not copy them to the dist to help catch bugs.
+ delete pkg.exports[key];
+ } else {
+ pkg.exports[key] = {
+ types: rewritePath(val, "d.ts"),
+ default: rewritePath(val, "js"),
+ };
+ }
+ } else if (typeof val === "object") {
+ for (const sub in val) {
+ if (typeof val[sub] === "string") {
+ val[sub] = rewritePath(val[sub], sub === "types" ? "d.ts" : "js");
+ }
+ }
+ }
+ }
+}
+
+if (pkg.sideEffects) {
+ pkg.sideEffects = pkg.sideEffects.map((p: string) => rewritePath(p, "js"));
+}
+
+if (pkg.files) {
+ pkg.files = pkg.files.map((p: string) => rewritePath(p, "js"));
+}
+
+if (pkg.bin) {
+ if (typeof pkg.bin === "string") {
+ pkg.bin = rewritePath(pkg.bin, "js");
+ } else if (typeof pkg.bin === "object") {
+ for (const key in pkg.bin) {
+ pkg.bin[key] = rewritePath(pkg.bin[key], "js");
+ }
+ }
+}
+
+function rewriteWorkspaceDependency(dependencies?: Record) {
+ if (!dependencies) return;
+ for (const [name, version] of Object.entries(dependencies)) {
+ if (typeof version === "string" && version.startsWith("workspace:")) {
+ // Read the actual version from the workspace package
+ // Handle both scoped (@scope/name) and unscoped (name) packages
+ const packageDir = name.includes("/") ? name.split("/")[1] : name;
+ const workspacePkgPath = `../${packageDir}/package.json`;
+ const workspacePkg = JSON.parse(readFileSync(workspacePkgPath, "utf8"));
+ dependencies[name] = `^${workspacePkg.version}`;
+ console.log(`🔗 Converted ${name}: ${version} → ^${workspacePkg.version}`);
+ }
+ }
+}
+
+// Convert workspace dependencies to published versions
+rewriteWorkspaceDependency(pkg.dependencies);
+rewriteWorkspaceDependency(pkg.devDependencies);
+rewriteWorkspaceDependency(pkg.peerDependencies);
+
+pkg.devDependencies = undefined;
+pkg.scripts = undefined;
+
+// Write the rewritten package.json
+writeFileSync("dist/package.json", JSON.stringify(pkg, null, 2));
+
+// Copy static files
+console.log("📄 Copying README.md...");
+copyFileSync("README.md", join("dist", "README.md"));
+
+// Lint the package to catch publishing issues
+console.log("🔍 Running publint...");
+const { messages, pkg: lintPkg } = await publint({
+ pkgDir: resolve("dist"),
+ level: "warning",
+ pack: false,
+});
+
+if (messages.length > 0) {
+ for (const message of messages) {
+ console.error(formatMessage(message, lintPkg));
+ }
+ process.exit(1);
+}
+
+console.log("📦 Package built successfully in dist/");
diff --git a/js/common/release.ts b/js/common/release.ts
new file mode 100644
index 0000000000..4a3b242f3f
--- /dev/null
+++ b/js/common/release.ts
@@ -0,0 +1,42 @@
+import { execFileSync } from "node:child_process";
+
+const dryRun = process.argv.includes("--dry-run") || process.env.DRY_RUN === "true";
+
+// Read package.json to get name and version
+const pkg = JSON.parse(await Bun.file("package.json").text());
+const { name, version } = pkg;
+
+// Skip the already-published check in dry-run mode so we always exercise
+// the build + publish manifest, even when the version is already on npm.
+if (!dryRun) {
+ let published = "0.0.0";
+ try {
+ published = execFileSync("npm", ["view", name, "version"], {
+ encoding: "utf8",
+ stdio: ["pipe", "pipe", "pipe"],
+ }).trim();
+ } catch {
+ // Package not published yet
+ }
+
+ if (version === published) {
+ console.log(`⏭️ ${name}@${version} already published, skipping`);
+ process.exit(0);
+ }
+}
+
+console.log(`📦 Building ${name}@${version}...`);
+execFileSync("bun", ["run", "build"], { stdio: "inherit" });
+
+if (dryRun) {
+ // `npm publish --dry-run` still hits the registry to check for version
+ // conflicts and errors out when the version is already published (which
+ // is the common case on PRs of main). `npm pack --dry-run` exercises the
+ // same packaging path without any registry roundtrip.
+ console.log(`🧪 Packing ${name}@${version} (dry-run)...`);
+ execFileSync("npm", ["pack", "--dry-run"], { stdio: "inherit", cwd: "dist" });
+} else {
+ console.log(`🚀 Publishing ${name}@${version}...`);
+ // Use npm for publishing to support OIDC trusted publishing
+ execFileSync("npm", ["publish", "--access", "public"], { stdio: "inherit", cwd: "dist" });
+}
diff --git a/js/common/vite-plugin-worklet.ts b/js/common/vite-plugin-worklet.ts
new file mode 100644
index 0000000000..f4a9029e7c
--- /dev/null
+++ b/js/common/vite-plugin-worklet.ts
@@ -0,0 +1,57 @@
+import { build } from "esbuild";
+import type { Plugin } from "vite";
+
+const SUFFIX = "?worklet";
+
+/**
+ * A Vite plugin that compiles AudioWorklet files and inlines them as blob URLs.
+ *
+ * Usage: import workletUrl from "./my-worklet.ts?worklet"
+ *
+ * The worklet file is compiled to JS with all dependencies bundled via esbuild,
+ * then inlined as a string. At runtime, a blob URL is created and exported.
+ * Pass the URL to audioWorklet.addModule().
+ */
+export function workletInline(alias?: Record): Plugin {
+ return {
+ name: "worklet-inline",
+ enforce: "pre",
+
+ async resolveId(source, importer) {
+ if (!source.endsWith(SUFFIX)) return;
+
+ const cleanSource = source.slice(0, -SUFFIX.length);
+ const resolved = await this.resolve(cleanSource, importer, { skipSelf: true });
+ if (!resolved) return;
+
+ return { id: resolved.id + SUFFIX, moduleSideEffects: false };
+ },
+
+ async load(id) {
+ if (!id.endsWith(SUFFIX)) return;
+
+ const filePath = id.slice(0, -SUFFIX.length);
+
+ if (this.addWatchFile) {
+ this.addWatchFile(filePath);
+ }
+
+ const result = await build({
+ entryPoints: [filePath],
+ bundle: true,
+ write: false,
+ format: "esm",
+ target: "esnext",
+ alias: alias,
+ });
+
+ const compiled = result.outputFiles[0].text;
+
+ return [
+ `const code = ${JSON.stringify(compiled)};`,
+ `const blob = new Blob([code], { type: "application/javascript" });`,
+ `export default URL.createObjectURL(blob);`,
+ ].join("\n");
+ },
+ };
+}
diff --git a/js/common/worklet.d.ts b/js/common/worklet.d.ts
new file mode 100644
index 0000000000..fe18cf9e1d
--- /dev/null
+++ b/js/common/worklet.d.ts
@@ -0,0 +1,4 @@
+declare module "*?worklet" {
+ const url: string;
+ export default url;
+}
diff --git a/js/hang-demo/README.md b/js/hang-demo/README.md
deleted file mode 100644
index 85a000f07f..0000000000
--- a/js/hang-demo/README.md
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-Media over QUIC (MoQ) is a live (media) delivery protocol utilizing QUIC.
-It utilizes new browser technologies such as [WebTransport](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API) and [WebCodecs](https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API) to provide WebRTC-like functionality.
-Despite the focus on media, the transport is generic and designed to scale to enormous viewership via clustered relay servers (aka a CDN).
-See [moq.dev](https://moq.dev) for more information.
-
-**Note:** this project is a [fork](https://moq.dev/blog/transfork) of the [IETF specification](https://datatracker.ietf.org/group/moq/documents/).
-The principles are the same but the implementation is exponentially simpler given a narrower focus (and no politics).
-
-# Usage
-These are demos, duh.
-We're using Vite but other bundlers should just work™.
-
-# License
-
-Licensed under either:
-
-- Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
-- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
diff --git a/js/hang-demo/package.json b/js/hang-demo/package.json
deleted file mode 100644
index da5a01232f..0000000000
--- a/js/hang-demo/package.json
+++ /dev/null
@@ -1,27 +0,0 @@
-{
- "name": "@moq/hang-demo",
- "private": true,
- "type": "module",
- "version": "0.1.0",
- "description": "Media over QUIC - Demo",
- "license": "(MIT OR Apache-2.0)",
- "repository": "github:moq-dev/moq",
- "scripts": {
- "dev": "vite --open",
- "build": "vite build",
- "check": "tsc --noEmit"
- },
- "dependencies": {
- "@moq/hang": "workspace:^",
- "@moq/hang-ui": "workspace:^"
- },
- "devDependencies": {
- "@tailwindcss/typography": "^0.5.16",
- "@tailwindcss/vite": "^4.1.13",
- "highlight.js": "^11.11.1",
- "tailwindcss": "^4.1.13",
- "typescript": "^5.9.2",
- "vite": "^6.3.6",
- "vite-plugin-solid": "^2.11.10"
- }
-}
diff --git a/js/hang-demo/src/config.ts b/js/hang-demo/src/config.ts
deleted file mode 100644
index ff32d40e01..0000000000
--- a/js/hang-demo/src/config.ts
+++ /dev/null
@@ -1,231 +0,0 @@
-import { Moq } from "@moq/hang";
-
-/**
- * A simple web component for configuring the relay URL and broadcast name.
- * Auto-discovers available broadcasts and shows them as clickable suggestions.
- */
-export default class HangConfig extends HTMLElement {
- #urlInput: HTMLInputElement;
- #pathInput: HTMLInputElement;
- #suggestions: HTMLDivElement;
- #discoveryConnection: Moq.Connection.Established | null = null;
- #discoveryTimeout: ReturnType | null = null;
- #discoveryAbort: AbortController | null = null;
-
- constructor() {
- super();
-
- // Create URL input
- const urlLabel = document.createElement("label");
- urlLabel.textContent = "Relay URL";
- urlLabel.style.cssText = "display: block; font-size: 0.85rem; color: #888; margin-bottom: 0.25rem;";
-
- this.#urlInput = document.createElement("input");
- this.#urlInput.type = "url";
- this.#urlInput.placeholder = "http://localhost:4443/anon";
- this.#urlInput.style.cssText = `
- width: 100%; padding: 0.5rem; margin-bottom: 0.75rem;
- background: #111; border: 1px solid #333; border-radius: 4px;
- color: #fff; font-family: monospace; font-size: 0.9rem;
- `;
-
- // Create path input
- const pathLabel = document.createElement("label");
- pathLabel.textContent = "Broadcast";
- pathLabel.style.cssText = "display: block; font-size: 0.85rem; color: #888; margin-bottom: 0.25rem;";
-
- this.#pathInput = document.createElement("input");
- this.#pathInput.type = "text";
- this.#pathInput.placeholder = "bbb";
- this.#pathInput.style.cssText = `
- width: 100%; padding: 0.5rem;
- background: #111; border: 1px solid #333; border-radius: 4px;
- color: #fff; font-family: monospace; font-size: 0.9rem;
- `;
-
- // Create suggestions container
- this.#suggestions = document.createElement("div");
- this.#suggestions.style.cssText = "margin-top: 0.5rem; font-size: 0.85rem;";
-
- // Append elements
- this.appendChild(urlLabel);
- this.appendChild(this.#urlInput);
- this.appendChild(pathLabel);
- this.appendChild(this.#pathInput);
- this.appendChild(this.#suggestions);
-
- // Event listeners
- this.#urlInput.addEventListener("input", () => this.#onUrlChange());
- this.#pathInput.addEventListener("input", () => this.#onPathChange());
- }
-
- connectedCallback() {
- this.style.cssText = "display: block; margin: 1rem 0;";
-
- // Initialize from attributes
- const url = this.getAttribute("url");
- const path = this.getAttribute("path");
-
- if (url) this.#urlInput.value = url;
- if (path) this.#pathInput.value = path;
-
- // Start discovery if URL is set
- if (url) this.#scheduleDiscovery();
- }
-
- disconnectedCallback() {
- this.#closeDiscovery();
- }
-
- static get observedAttributes() {
- return ["url", "path"];
- }
-
- attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
- if (name === "url" && newValue !== this.#urlInput.value) {
- this.#urlInput.value = newValue || "";
- this.#scheduleDiscovery();
- } else if (name === "path" && newValue !== this.#pathInput.value) {
- this.#pathInput.value = newValue || "";
- }
- }
-
- get url(): string {
- return this.#urlInput.value;
- }
-
- get path(): string {
- return this.#pathInput.value;
- }
-
- #onUrlChange() {
- this.setAttribute("url", this.#urlInput.value);
- this.#dispatchChange();
- this.#scheduleDiscovery();
- }
-
- #onPathChange() {
- this.setAttribute("path", this.#pathInput.value);
- this.#dispatchChange();
- }
-
- #dispatchChange() {
- this.dispatchEvent(
- new CustomEvent("change", {
- detail: { url: this.url, path: this.path },
- bubbles: true,
- }),
- );
- }
-
- #scheduleDiscovery() {
- // Debounce discovery
- if (this.#discoveryTimeout) {
- clearTimeout(this.#discoveryTimeout);
- }
- this.#discoveryTimeout = setTimeout(() => this.#discover(), 500);
- }
-
- async #discover() {
- const relayUrl = this.#urlInput.value.trim();
- if (!relayUrl) {
- this.#suggestions.innerHTML = "";
- return;
- }
-
- // Abort any in-flight discovery to prevent orphaned connections
- this.#discoveryAbort?.abort();
- this.#discoveryAbort = new AbortController();
- const signal = this.#discoveryAbort.signal;
-
- this.#closeDiscovery();
- this.#suggestions.innerHTML = 'Discovering... ';
-
- try {
- const url = new URL(relayUrl);
- const connection = await Moq.Connection.connect(url);
-
- // Check if this discovery was aborted while connecting
- if (signal.aborted) {
- connection.close();
- return;
- }
-
- this.#discoveryConnection = connection;
-
- const announced = connection.announced(Moq.Path.empty());
- const broadcasts: string[] = [];
- const timeout = 2000;
- const startTime = Date.now();
-
- while (Date.now() - startTime < timeout) {
- const remaining = Math.max(0, timeout - (Date.now() - startTime));
- const timeoutPromise = new Promise((resolve) =>
- setTimeout(() => resolve(undefined), remaining),
- );
-
- const entry = await Promise.race([announced.next(), timeoutPromise]);
- if (entry === undefined) break;
- if (entry.active) {
- broadcasts.push(entry.path);
- this.#renderSuggestions(broadcasts);
- }
- }
-
- announced.close();
-
- if (broadcasts.length === 0) {
- this.#suggestions.innerHTML = 'No broadcasts found ';
- }
- } catch (err) {
- console.error("Discovery error:", err);
- this.#suggestions.innerHTML = 'Discovery unavailable ';
- } finally {
- // Close the connection after discovery to avoid resource leaks
- this.#closeDiscovery();
- }
- }
-
- #renderSuggestions(broadcasts: string[]) {
- this.#suggestions.innerHTML = "";
-
- const label = document.createElement("span");
- label.textContent = "Available: ";
- label.style.color = "#666";
- this.#suggestions.appendChild(label);
-
- broadcasts.forEach((name) => {
- const tag = document.createElement("button");
- tag.type = "button";
- tag.textContent = name;
- tag.style.cssText = `
- background: #1a2e1a; color: #4ade80; border: 1px solid #2d4a2d;
- padding: 0.2rem 0.5rem; margin: 0 0.25rem; border-radius: 4px;
- font-size: 0.8rem; font-family: monospace; cursor: pointer;
- transition: background 0.15s, border-color 0.15s;
- `;
- tag.addEventListener("mouseenter", () => {
- tag.style.background = "#2d4a2d";
- tag.style.borderColor = "#4ade80";
- });
- tag.addEventListener("mouseleave", () => {
- tag.style.background = "#1a2e1a";
- tag.style.borderColor = "#2d4a2d";
- });
- tag.addEventListener("click", () => {
- this.#pathInput.value = name;
- this.#onPathChange();
- });
- this.#suggestions.appendChild(tag);
- });
- }
-
- #closeDiscovery() {
- if (this.#discoveryConnection) {
- this.#discoveryConnection.close();
- this.#discoveryConnection = null;
- }
- }
-}
-
-customElements.define("hang-config", HangConfig);
diff --git a/js/hang-demo/src/index.html b/js/hang-demo/src/index.html
deleted file mode 100644
index adbc0caf71..0000000000
--- a/js/hang-demo/src/index.html
+++ /dev/null
@@ -1,166 +0,0 @@
-
-
-
-
-
-
- MoQ Demo
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Other demos:
-
-
- Tips:
-
- You can find the source code for this demo in js/hang-demo/src/index.html.
- Yes I know it's confusing when a command automatically opens a browser window.
-
-
- This demo uses
- http so it's extra not secure.
- It works by insecurely fetching the certificate hash and telling WebTransport to trust it.
- If you're going to run this code in production, you'll need a valid certificate (ex. LetsEncrypt) and use
- https.
-
-
-
- You can instanciate the player via the provided <hang-watch> Web Component .
- Either modify HTML attributes like <hang-watch paused> or use the
- Javascript API.
- The Javascript API is still evolving, so I recommend the Web Component for now.
-
-
- You can provide your own canvas element and use CSS to modify it.
- Unfortunately, you can't use the HTML width/height attributes because of how OffscreenCanvas
- works.
- For example:
-
<hang-watch url="http://localhost:4443/" room="demo" path="bbb">
- <!-- Optionally provide a custom canvas element that we can style as needed -->
- <canvas style="max-width: 100%; height: auto; border-radius: 4px;"></canvas>
-</hang-watch>
-
-
- Don't want video? Don't provide a canvas!
- It won't be downloaded, decoded, or rendered.
- This includes when the video is paused, minimized, not in the DOM, or scrolled out of view.
- wowee the bandwidth savings!
-
-
- Audio may start muted because the browser can require user interaction before autoplaying.
- You can unmute it by removing the muted property or calling watch.audio.muted.set(false) via the Javascript API.
- And of course, nothing is downloaded while it's muted.
-
-
-
- The Javascript API is far more powerful and you can access properties directly:
-
-
const watch = document.getElementById("watch");
-watch.audio.muted.set(true);
-
-
-
-
- All of the properties are reactive using a hand-rolled signals library: `@moq/signals`.
- You could use it... or you can use the provided `react` and `solid` helpers:
-
-
-import { Watch } from "@moq/hang";
-import solid from "@moq/signals/solid";
-
-function Volume(hang: Watch) {
- // Switch to `react` if you're using React, duh.
- const volume = solid(hang.volume);
-
- // Return a div that displays the volume.
- return <div>
- Volume: {volume()}
- </div>
-}
-
-
-
- Using something more niche? There's also a subscribe() method to trigger a callback on change.
-
-
-const cleanup = hang.volume.subscribe((volume) => {
- document.getElementById("volume-value").textContent = `${volume * 100}%`;
-});
-
-// Cleanup the subscription when no longer needed.
-cleanup();
-
-
-
-
- The connection and broadcast are automatically reloaded.
- Try running multiple terminals and kill the broadcast to see what happens.
-
-
# Run the relay and web server in another terminal or the background.
-just relay &
-just web &
-
-just pub bbb
-# Kill it with ctrl+C
-
-# Republish the same broadcast, the player will reconnect.
-just pub bbb
-
-
-
- If the Big Bunny is making you sick, you can use other inferior test videos or the publish demo .
- For example,
- Try running just pub tos in a new terminal and then watch robots bang .
- This command uses ffmpeg to produce a fragmented MP4 file piped over stdout and then sent over the network.
- Yeah it's pretty gross.
-
-
- If you want to do things more efficiently, you can use the
- Gstreamer plugin .
- It's pretty crude and doesn't handle all pipeline events; contributions welcome!
-
-
-
-
-
-
diff --git a/js/hang-demo/src/index.ts b/js/hang-demo/src/index.ts
deleted file mode 100644
index 355a68d27f..0000000000
--- a/js/hang-demo/src/index.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import "./highlight";
-import "@moq/hang-ui/watch";
-import HangSupport from "@moq/hang/support/element";
-import HangWatch from "@moq/hang/watch/element";
-import HangConfig from "./config";
-
-export { HangSupport, HangWatch, HangConfig };
-
-const watch = document.querySelector("hang-watch") as HangWatch | undefined;
-const config = document.querySelector("hang-config") as HangConfig | undefined;
-
-if (!watch) throw new Error("unable to find element");
-
-// If query params are provided, use them.
-const urlParams = new URLSearchParams(window.location.search);
-const path = urlParams.get("path");
-const url = urlParams.get("url");
-
-if (path) {
- watch.setAttribute("path", path);
- config?.setAttribute("path", path);
-}
-if (url) {
- watch.setAttribute("url", url);
- config?.setAttribute("url", url);
-}
-
-// Sync config changes to the watch element.
-config?.addEventListener("change", (e) => {
- const { url, path } = (e as CustomEvent).detail;
- if (url) watch.setAttribute("url", url);
- if (path) watch.setAttribute("path", path);
-});
diff --git a/js/hang-demo/src/meet.html b/js/hang-demo/src/meet.html
deleted file mode 100644
index 58d2612474..0000000000
--- a/js/hang-demo/src/meet.html
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
-
-
- MoQ Demo
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Other demos:
-
-
- Tips:
-
- Broadcasts are discovered/announced via the relay server; no need for a separate signaling server.
- You could download your own broadcast... or save some bandwidth and perform a local preview.
- You still get a signal informing you when other viewers can see your broadcast.
-
-
- A "room" of broadcasts is little more than a shared prefix.
- If you publish to demo/foo and demo/bar, then using demo will render both.
- You can provide the path of the broadcast via the path attribute.
-
-
-
- I hightly recommend using the Javascript API instead of the Web Component, even if the API is still evolving.
- There's just too much business logic involved in determining how to render broadcasts.
- This demo uses the most basic grid layout with near-zero styling.
- It's ugly on purpose.
- There's a million different ways to render a collection of broadcasts and it's up to your application to decide.
-
-
- My application renders to a shared canvas instead.
- If you're interested in the details, it's similar to the VideoRenderer class.
- You should instead use this library to discover broadcasts, download them, and perhaps render them.
-
-
-
-
-
-
diff --git a/js/hang-demo/src/meet.ts b/js/hang-demo/src/meet.ts
deleted file mode 100644
index b7b23062d7..0000000000
--- a/js/hang-demo/src/meet.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import "./highlight";
-import "@moq/hang-ui/publish";
-import HangMeet from "@moq/hang/meet/element";
-import HangPublish from "@moq/hang/publish/element";
-import HangSupport from "@moq/hang/support/element";
-
-export { HangMeet, HangSupport, HangPublish };
diff --git a/js/hang-demo/src/publish.html b/js/hang-demo/src/publish.html
deleted file mode 100644
index 2d74b141ac..0000000000
--- a/js/hang-demo/src/publish.html
+++ /dev/null
@@ -1,94 +0,0 @@
-
-
-
-
-
-
- MoQ Demo
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Other demos:
-
-
- Tips:
-
- This page creates a broadcast called `me` by default.
- You can use query parameters to use a different path and create
- multiple broadcasts.
-
-
- Reusing the same broadcast path means viewers will automatically reconnect to the new session.
- Try reloading the page and broadcasting again; viewers will automatically reconnect.
-
-
-
- Media only flows over the network when requested!
- Connecting to a relay means the broadcast is advertised as available, but nothing is transferred until there's
- at least one viewer per track.
- If there's multiple viewers, the relay will fan out the media to all of them.
-
-
- You can create a broadcaster via the provided <hang-publish> Web Component .
- Either modify HTML attributes like <hang-publish source="camera" audio />
- or access the element's Javascript API:
-
const publish = document.getElementById("publish");
-publish.broadcast.audio.enabled.set(true);
-
- And of course you can use the Javascript API directly instead of the Web Component.
- It's a bit more complicated and subject to change, but it gives you more control.
-
-
-
- You're not limited to web publishing either.
- Try running just pub tos in a new terminal and then watch robots bang .
- This uses ffmpeg to produce a fragmented MP4 file piped over stdout then sent over the network.
- Yeah it's pretty gross.
-
-
- If you want to do things more efficiently, you can use the
- Gstreamer plugin .
- It's pretty crude and doesn't handle all pipeline events; contributions welcome!
-
-
-
- This demo uses `http://` so it's not secure.
- It works by fetching the certificate hash (via HTTP) and providing that to WebTransport, which requires HTTPS.
- To run this in production, you'll need a valid certificate (ex. letsencrypt) and to use `https://`.
-
-
-
-
-
-
diff --git a/js/hang-demo/src/publish.ts b/js/hang-demo/src/publish.ts
deleted file mode 100644
index ad01dc9f0f..0000000000
--- a/js/hang-demo/src/publish.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import "./highlight";
-import "@moq/hang-ui/publish";
-
-// We need to import Web Components with fully-qualified paths because of tree-shaking.
-import HangPublish from "@moq/hang/publish/element";
-import HangSupport from "@moq/hang/support/element";
-
-export { HangPublish, HangSupport };
-
-const publish = document.querySelector("hang-publish") as HangPublish;
-const watch = document.getElementById("watch") as HTMLAnchorElement;
-const watchName = document.getElementById("watch-name") as HTMLSpanElement;
-
-const urlParams = new URLSearchParams(window.location.search);
-const path = urlParams.get("path");
-if (path) {
- publish.setAttribute("path", path);
- watch.href = `index.html?path=${path}`;
- watchName.textContent = path;
-}
diff --git a/js/hang-demo/src/support.html b/js/hang-demo/src/support.html
deleted file mode 100644
index f31267ad3d..0000000000
--- a/js/hang-demo/src/support.html
+++ /dev/null
@@ -1,48 +0,0 @@
-
-
-
-
-
-
- MoQ Demo
-
-
-
-
-
-
-
-
-
- Other demos:
-
-
- Tips:
-
- The hang-support element is used to check if the browser supports all of the web APIs we need.
-
-
- Control which features are tested via the mode attribute.
- By default, all features are tested.
-
- const mode: "core" | "watch" | "publish" | "all" = "all";
-
-
- Only show the element if some features are unsupported.
- By default, the element is always shown, but it's useful to only show it on partial/missing support.
-
- const show: "full" | "partial" | "none" = "full";
-
-
-
- NOTE: Firefox and Safari incorrectly detect hardware acceleration.
-
-
-
-
-
-
diff --git a/js/hang-demo/src/support.ts b/js/hang-demo/src/support.ts
deleted file mode 100644
index 955d65e55c..0000000000
--- a/js/hang-demo/src/support.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import "./highlight";
-
-// We need to import Web Components with fully-qualified paths because of tree-shaking.
-import HangSupport from "@moq/hang/support/element";
-export { HangSupport };
diff --git a/js/hang-demo/tsconfig.json b/js/hang-demo/tsconfig.json
deleted file mode 100644
index 602e70cd54..0000000000
--- a/js/hang-demo/tsconfig.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "extends": "../tsconfig.json",
- "compilerOptions": {
- "outDir": "dist"
- },
- "include": ["src"]
-}
diff --git a/js/hang-demo/vite.config.ts b/js/hang-demo/vite.config.ts
deleted file mode 100644
index a64e72788d..0000000000
--- a/js/hang-demo/vite.config.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import tailwindcss from "@tailwindcss/vite";
-import { defineConfig } from "vite";
-import solidPlugin from "vite-plugin-solid";
-
-export default defineConfig({
- root: "src",
- plugins: [tailwindcss(), solidPlugin()],
- build: {
- target: "esnext",
- sourcemap: process.env.NODE_ENV === "production" ? false : "inline",
- rollupOptions: {
- input: {
- watch: "index.html",
- publish: "publish.html",
- support: "support.html",
- meet: "meet.html",
- },
- },
- },
- server: {
- // TODO: properly support HMR
- hmr: false,
- },
- optimizeDeps: {
- // No idea why this needs to be done, but I don't want to figure it out.
- exclude: ["@libav.js/variant-opus-af"],
- },
-});
diff --git a/js/hang-ui/README.md b/js/hang-ui/README.md
deleted file mode 100644
index 776f58ea0e..0000000000
--- a/js/hang-ui/README.md
+++ /dev/null
@@ -1,110 +0,0 @@
-
-
-
-
-# @moq/hang-ui
-
-[](https://www.npmjs.com/package/@moq/hang-ui)
-[](https://www.typescriptlang.org/)
-
-A TypeScript library for interacting with @moq/hang Web Components. Provides methods to control playback and publish sources, as well as status of the connection.
-
-## Installation
-
-```bash
-npm add @moq/hang-ui
-# or
-pnpm add @moq/hang-ui
-yarn add @moq/hang-ui
-bun add @moq/hang-ui
-```
-
-## Web Components
-
-Currently, there are two Web Components provided by @moq/hang-ui:
-
-- ``
-- ``
-
-Here's how you can use them (see also @moq/hang-demo for a complete example):
-
-```html
-
-
-
-
-
-```
-
-```html
-
-
-
-
-
-```
-
-## Project Structure
-The `@moq/hang-ui` package is organized into modular components and utilities:
-
-```text
-src/
-├── publish/ # Publishing UI components
-│ ├── components/ # UI controls for publishing
-│ ├── hooks/ # Custom Solid hooks for publish UI
-│ ├── styles/ # CSS styles for publish UI
-│ ├── context.tsx # Context provider for publish state
-│ ├── element.tsx # Main publish UI component
-│ └── index.tsx # Entry point for publish UI
-│
-├── watch/ # Watching/playback UI components
-│ ├── components/ # UI controls for watching
-│ ├── hooks/ # Custom Solid hooks for watch UI
-│ ├── styles/ # CSS styles for watch UI
-│ ├── context.tsx # Context provider for watch state
-│ ├── element.tsx # Main watch UI component
-│ └── index.tsx # Entry point for watch UI
-│
-└── shared/ # Shared components and utilities
- ├── components/ # Reusable UI components
- │ ├── button/ # Button component
- │ ├── icon/ # Icon component
- │ └── stats/ # Statistics and monitoring components
- ├── flex.css # Flexbox utilities
- └── variables.css # CSS variables and theme
-
-```
-
-### Module Overview
-
-#### **publish/**
-Contains all UI components related to media publishing. It provides controls for selecting media sources (camera, screen, microphone, file) and managing the publishing state.
-
-- **MediaSourceSelector**: Allows users to choose their media source
-- **PublishControls**: Main control panel for publishing
-- **Source buttons**: Individual buttons for camera, screen, microphone, file, and "nothing" sources
-- **PublishStatusIndicator**: Displays connection and publishing status
-
-#### **watch/**
-Implements the video player UI with controls for watching live streams. Includes playback controls, quality selection, and buffering indicators.
-
-- **WatchControls**: Main control panel for the video player
-- **PlayPauseButton**: Play/pause toggle
-- **VolumeSlider**: Audio volume control
-- **LatencySlider**: Adjust playback latency
-- **QualitySelector**: Switch between quality levels
-- **FullscreenButton**: Toggle fullscreen mode
-- **BufferingIndicator**: Visual feedback during buffering
-- **StatsButton**: Toggle statistics panel
-
-#### **shared/**
-Common components and utilities used across the package.
-
-- **Button**: Reusable button component with consistent styling
-- **Icon**: Icon wrapper component
-- **Stats**: Provides real-time statistics monitoring for both audio and video streams. Uses a provider pattern to collect and display metrics.
-- **CSS utilities**: Shared styles, variables, and flexbox utilities
diff --git a/js/hang-ui/package.json b/js/hang-ui/package.json
deleted file mode 100644
index baed1db0a8..0000000000
--- a/js/hang-ui/package.json
+++ /dev/null
@@ -1,41 +0,0 @@
-{
- "name": "@moq/hang-ui",
- "type": "module",
- "version": "0.1.1",
- "description": "Media over QUIC library UI components",
- "license": "(MIT OR Apache-2.0)",
- "repository": "github:moq-dev/moq",
- "exports": {
- "./publish": "./src/publish/index.tsx",
- "./watch": "./src/watch/index.tsx"
- },
- "sideEffects": [
- "./src/publish/index.tsx",
- "./src/watch/index.tsx"
- ],
- "scripts": {
- "build": "bun run clean && vite build && bun ../scripts/package.ts",
- "check": "tsc --noEmit",
- "clean": "rimraf dist",
- "fix": "biome check src --fix",
- "test": "vitest",
- "release": "bun ../scripts/release.ts"
- },
- "peerDependencies": {
- "@moq/hang": "workspace:^0.1.0",
- "@moq/signals": "workspace:^0.1.0"
- },
- "devDependencies": {
- "@solidjs/testing-library": "^0.8.10",
- "@testing-library/jest-dom": "^6.9.1",
- "happy-dom": "^20.0.11",
- "rimraf": "^6.0.1",
- "solid-element": "^1.9.1",
- "solid-js": "^1.9.10",
- "unplugin-solid": "^1.0.0",
- "typescript": "^5.9.2",
- "vite": "^7.3.1",
- "vite-plugin-solid": "^2.11.10",
- "vitest": "^3.2.4"
- }
-}
diff --git a/js/hang-ui/src/publish/components/CameraSourceButton.tsx b/js/hang-ui/src/publish/components/CameraSourceButton.tsx
deleted file mode 100644
index 2493f3baa1..0000000000
--- a/js/hang-ui/src/publish/components/CameraSourceButton.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Show } from "solid-js";
-import Button from "../../shared/components/button/button";
-import * as Icon from "../../shared/components/icon/icon";
-import usePublishUIContext from "../hooks/use-publish-ui";
-import MediaSourceSourceSelector from "./MediaSourceSelector";
-
-export default function CameraSourceButton() {
- const context = usePublishUIContext();
- const onClick = () => {
- if (context.hangPublish.source.peek() === "camera") {
- // Camera already selected, toggle video.
- context.hangPublish.invisible.update((invisible) => !invisible);
- } else {
- context.hangPublish.source.set("camera");
- context.hangPublish.invisible.set(false);
- }
- };
-
- const onSourceSelected = (sourceId: MediaDeviceInfo["deviceId"]) => {
- const video = context.hangPublish.video.peek();
- if (!video || !("device" in video)) return;
-
- video.device.preferred.set(sourceId);
- };
-
- return (
-
-
-
-
-
-
-
-
- );
-}
diff --git a/js/hang-ui/src/publish/components/FileSourceButton.tsx b/js/hang-ui/src/publish/components/FileSourceButton.tsx
deleted file mode 100644
index 3855093b13..0000000000
--- a/js/hang-ui/src/publish/components/FileSourceButton.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { createSignal } from "solid-js";
-import Button from "../../shared/components/button/button";
-import * as Icon from "../../shared/components/icon/icon";
-import usePublishUIContext from "../hooks/use-publish-ui";
-
-export default function FileSourceButton() {
- const [fileInputRef, setFileInputRef] = createSignal();
- const context = usePublishUIContext();
- const onClick = () => fileInputRef()?.click();
- const onChange = (event: Event) => {
- const castedInputEl = event.target as HTMLInputElement;
- const file = castedInputEl.files?.[0];
-
- if (file) {
- context.setFile(file);
- castedInputEl.value = "";
- }
- };
-
- return (
- <>
-
-
-
-
- >
- );
-}
diff --git a/js/hang-ui/src/publish/components/MediaSourceSelector.tsx b/js/hang-ui/src/publish/components/MediaSourceSelector.tsx
deleted file mode 100644
index b414f8cc89..0000000000
--- a/js/hang-ui/src/publish/components/MediaSourceSelector.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { createSignal, For, Show } from "solid-js";
-import Button from "../../shared/components/button/button";
-import * as Icon from "../../shared/components/icon/icon";
-
-type MediaSourceSelectorProps = {
- sources?: MediaDeviceInfo[];
- selectedSource?: MediaDeviceInfo["deviceId"];
- onSelected?: (sourceId: MediaDeviceInfo["deviceId"]) => void;
-};
-
-export default function MediaSourceSelector(props: MediaSourceSelectorProps) {
- const [sourcesVisible, setSourcesVisible] = createSignal(false);
-
- const toggleSourcesVisible = () => setSourcesVisible((visible) => !visible);
-
- return (
- <>
-
- }>
-
-
-
-
- props.onSelected?.(e.currentTarget.value as MediaDeviceInfo["deviceId"])}
- >
-
- {(source) => {source.label} }
-
-
-
- >
- );
-}
diff --git a/js/hang-ui/src/publish/components/MicrophoneSourceButton.tsx b/js/hang-ui/src/publish/components/MicrophoneSourceButton.tsx
deleted file mode 100644
index 5d741ad818..0000000000
--- a/js/hang-ui/src/publish/components/MicrophoneSourceButton.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Show } from "solid-js";
-import Button from "../../shared/components/button/button";
-import * as Icon from "../../shared/components/icon/icon";
-import usePublishUIContext from "../hooks/use-publish-ui";
-import MediaSourceSourceSelector from "./MediaSourceSelector";
-
-export default function MicrophoneSourceButton() {
- const context = usePublishUIContext();
- const onClick = () => {
- if (context.hangPublish.source.peek() === "camera") {
- // Camera already selected, toggle audio.
- context.hangPublish.muted.update((muted) => !muted);
- } else {
- context.hangPublish.source.set("camera");
- context.hangPublish.muted.set(false);
- }
- };
-
- const onSourceSelected = (sourceId: MediaDeviceInfo["deviceId"]) => {
- const audio = context.hangPublish.audio.peek();
- if (!audio || !("device" in audio)) return;
-
- audio.device.preferred.set(sourceId);
- };
-
- return (
-
-
-
-
-
-
-
-
- );
-}
diff --git a/js/hang-ui/src/publish/components/NothingSourceButton.tsx b/js/hang-ui/src/publish/components/NothingSourceButton.tsx
deleted file mode 100644
index dd238666cf..0000000000
--- a/js/hang-ui/src/publish/components/NothingSourceButton.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import Button from "../../shared/components/button/button";
-import * as Icon from "../../shared/components/icon/icon";
-import usePublishUIContext from "../hooks/use-publish-ui";
-
-export default function NothingSourceButton() {
- const context = usePublishUIContext();
- const onClick = () => {
- context.hangPublish.source.set(undefined);
- context.hangPublish.muted.set(true);
- context.hangPublish.invisible.set(true);
- };
-
- return (
-
-
-
-
-
- );
-}
diff --git a/js/hang-ui/src/publish/components/PublishControls.tsx b/js/hang-ui/src/publish/components/PublishControls.tsx
deleted file mode 100644
index d56e93cc2d..0000000000
--- a/js/hang-ui/src/publish/components/PublishControls.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import CameraSourceButton from "./CameraSourceButton";
-import FileSourceButton from "./FileSourceButton";
-import MicrophoneSourceButton from "./MicrophoneSourceButton";
-import NothingSourceButton from "./NothingSourceButton";
-import PublishStatusIndicator from "./PublishStatusIndicator";
-import ScreenSourceButton from "./ScreenSourceButton";
-
-export default function PublishControls() {
- return (
-
-
- Source:
-
-
-
-
-
-
-
-
- );
-}
diff --git a/js/hang-ui/src/publish/components/PublishStatusIndicator.tsx b/js/hang-ui/src/publish/components/PublishStatusIndicator.tsx
deleted file mode 100644
index f4d9d6c232..0000000000
--- a/js/hang-ui/src/publish/components/PublishStatusIndicator.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Match, Switch } from "solid-js";
-import usePublishUIContext from "../hooks/use-publish-ui";
-
-export default function PublishStatusIndicator() {
- const context = usePublishUIContext();
-
- return (
-
-
- 🔴 No URL
- 🔴 Disconnected
- 🟡 Connecting...
- 🟡 Select Source
- 🟢 Video Only
- 🟢 Audio Only
- 🟢 Live
-
-
- );
-}
diff --git a/js/hang-ui/src/publish/components/ScreenSourceButton.tsx b/js/hang-ui/src/publish/components/ScreenSourceButton.tsx
deleted file mode 100644
index 528d835495..0000000000
--- a/js/hang-ui/src/publish/components/ScreenSourceButton.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import Button from "../../shared/components/button/button";
-import * as Icon from "../../shared/components/icon/icon";
-import usePublishUIContext from "../hooks/use-publish-ui";
-
-export default function ScreenSourceButton() {
- const context = usePublishUIContext();
- const onClick = () => {
- context.hangPublish.source.set("screen");
- context.hangPublish.invisible.set(false);
- context.hangPublish.muted.set(false);
- };
-
- return (
-
-
-
-
-
- );
-}
diff --git a/js/hang-ui/src/publish/context.tsx b/js/hang-ui/src/publish/context.tsx
deleted file mode 100644
index f31f1d293f..0000000000
--- a/js/hang-ui/src/publish/context.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-import type HangPublish from "@moq/hang/publish/element";
-import type { JSX } from "solid-js";
-import { createContext, createEffect, createSignal } from "solid-js";
-
-type PublishStatus = "no-url" | "disconnected" | "connecting" | "live" | "audio-only" | "video-only" | "select-source";
-
-type PublishUIContextValue = {
- hangPublish: HangPublish;
- cameraDevices: () => MediaDeviceInfo[];
- microphoneDevices: () => MediaDeviceInfo[];
- publishStatus: () => PublishStatus;
- microphoneActive: () => boolean;
- cameraActive: () => boolean;
- screenActive: () => boolean;
- fileActive: () => boolean;
- nothingActive: () => boolean;
- selectedCameraSource?: () => MediaDeviceInfo["deviceId"] | undefined;
- selectedMicrophoneSource?: () => MediaDeviceInfo["deviceId"] | undefined;
- setFile: (file: File) => void;
-};
-
-type PublishUIContextProviderProps = {
- hangPublish: HangPublish;
- children: JSX.Element;
-};
-
-export const PublishUIContext = createContext();
-
-export default function PublishUIContextProvider(props: PublishUIContextProviderProps) {
- const [cameraDevices, setCameraMediaDevices] = createSignal([]);
- const [selectedCameraSource, setSelectedCameraSource] = createSignal();
- const [microphoneDevices, setMicrophoneMediaDevices] = createSignal([]);
- const [selectedMicrophoneSource, setSelectedMicrophoneSource] = createSignal<
- MediaDeviceInfo["deviceId"] | undefined
- >();
- const [cameraActive, setCameraActive] = createSignal(false);
- const [screenActive, setScreenActive] = createSignal(false);
- const [microphoneActive, setMicrophoneActive] = createSignal(false);
- const [fileActive, setFileActive] = createSignal(false);
- const [nothingActive, setNothingActive] = createSignal(false);
- const [publishStatus, setPublishStatus] = createSignal("no-url");
-
- const setFile = (file: File) => {
- props.hangPublish.source.set(file);
- props.hangPublish.invisible.set(false);
- props.hangPublish.muted.set(false);
- };
-
- const value: PublishUIContextValue = {
- hangPublish: props.hangPublish,
- cameraDevices,
- microphoneDevices,
- publishStatus,
- cameraActive,
- screenActive,
- microphoneActive,
- fileActive,
- setFile,
- nothingActive,
- selectedCameraSource,
- selectedMicrophoneSource,
- };
-
- createEffect(() => {
- const publish = props.hangPublish;
-
- publish.signals.effect((effect) => {
- const clearCameraDevices = () => setCameraMediaDevices([]);
- const video = effect.get(publish.video);
-
- if (!video || !("device" in video)) {
- clearCameraDevices();
- return;
- }
-
- const devices = effect.get(video.device.available);
- if (!devices || devices.length < 2) {
- clearCameraDevices();
- return;
- }
-
- setCameraMediaDevices(devices);
- });
-
- publish.signals.effect((effect) => {
- const clearMicrophoneDevices = () => setMicrophoneMediaDevices([]);
- const audio = effect.get(publish.audio);
-
- if (!audio || !("device" in audio)) {
- clearMicrophoneDevices();
- return;
- }
-
- const enabled = effect.get(publish.broadcast.audio.enabled);
- if (!enabled) {
- clearMicrophoneDevices();
- return;
- }
-
- const devices = effect.get(audio.device.available);
- if (!devices || devices.length < 2) {
- clearMicrophoneDevices();
- return;
- }
-
- setMicrophoneMediaDevices(devices);
- });
-
- publish.signals.effect((effect) => {
- const selectedSource = effect.get(publish.source);
- setNothingActive(selectedSource === undefined);
- });
-
- publish.signals.effect((effect) => {
- const audioActive = !effect.get(publish.muted);
- setMicrophoneActive(audioActive);
- });
-
- publish.signals.effect((effect) => {
- const videoSource = effect.get(publish.source);
- const videoActive = effect.get(publish.video);
-
- if (videoActive && videoSource === "camera") {
- setCameraActive(true);
- setScreenActive(false);
- } else if (videoActive && videoSource === "screen") {
- setScreenActive(true);
- setCameraActive(false);
- } else {
- setCameraActive(false);
- setScreenActive(false);
- }
- });
-
- publish.signals.effect((effect) => {
- const video = effect.get(publish.video);
-
- if (!video || !("device" in video)) return;
-
- const requested = effect.get(video.device.requested);
- setSelectedCameraSource(requested);
- });
-
- publish.signals.effect((effect) => {
- const audio = effect.get(publish.audio);
-
- if (!audio || !("device" in audio)) return;
-
- const requested = effect.get(audio.device.requested);
- setSelectedMicrophoneSource(requested);
- });
-
- publish.signals.effect((effect) => {
- const url = effect.get(publish.connection.url);
- const status = effect.get(publish.connection.status);
- const audio = effect.get(publish.broadcast.audio.source);
- const video = effect.get(publish.broadcast.video.source);
-
- if (!url) {
- setPublishStatus("no-url");
- } else if (status === "disconnected") {
- setPublishStatus("disconnected");
- } else if (status === "connecting") {
- setPublishStatus("connecting");
- } else if (!audio && !video) {
- setPublishStatus("select-source");
- } else if (!audio && video) {
- setPublishStatus("video-only");
- } else if (audio && !video) {
- setPublishStatus("audio-only");
- } else if (audio && video) {
- setPublishStatus("live");
- }
- });
-
- publish.signals.effect((effect) => {
- const selectedSource = effect.get(publish.source);
- setFileActive(selectedSource instanceof File);
- });
- });
-
- return {props.children} ;
-}
diff --git a/js/hang-ui/src/publish/element.tsx b/js/hang-ui/src/publish/element.tsx
deleted file mode 100644
index 91974f1731..0000000000
--- a/js/hang-ui/src/publish/element.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import type HangPublish from "@moq/hang/publish/element";
-import PublishControls from "./components/PublishControls";
-import PublishControlsContextProvider from "./context";
-import styles from "./styles/index.css?inline";
-
-export function PublishUI(props: { publish: HangPublish }) {
- return (
- <>
-
-
-
-
-
- >
- );
-}
diff --git a/js/hang-ui/src/publish/hooks/use-publish-ui.ts b/js/hang-ui/src/publish/hooks/use-publish-ui.ts
deleted file mode 100644
index 06da0a8664..0000000000
--- a/js/hang-ui/src/publish/hooks/use-publish-ui.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useContext } from "solid-js";
-import { PublishUIContext } from "../context";
-
-export default function usePublishUIContext() {
- const context = useContext(PublishUIContext);
-
- if (!context) {
- throw new Error("usePublishUIContext must be used within a PublishUIContextProvider");
- }
-
- return context;
-}
diff --git a/js/hang-ui/src/publish/index.tsx b/js/hang-ui/src/publish/index.tsx
deleted file mode 100644
index af70b6b4e5..0000000000
--- a/js/hang-ui/src/publish/index.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import type HangPublish from "@moq/hang/publish/element";
-import { customElement } from "solid-element";
-import { createSignal, onMount } from "solid-js";
-import { Show } from "solid-js/web";
-import { PublishUI } from "./element.tsx";
-
-customElement("hang-publish-ui", (_, { element }) => {
- const [nested, setNested] = createSignal();
-
- onMount(async () => {
- await customElements.whenDefined("hang-publish");
- const publishEl = element.querySelector("hang-publish");
- setNested(publishEl ? (publishEl as HangPublish) : undefined);
- });
-
- return (
-
- {(publish: HangPublish) => }
-
- );
-});
diff --git a/js/hang-ui/src/publish/styles/index.css b/js/hang-ui/src/publish/styles/index.css
deleted file mode 100644
index bead668a67..0000000000
--- a/js/hang-ui/src/publish/styles/index.css
+++ /dev/null
@@ -1,55 +0,0 @@
-@import "../../shared/variables.css";
-@import "../../shared/flex.css";
-@import "../../shared/components/button/button.css";
-
-.publishControlsContainer {
- display: flex;
- justify-content: space-around;
- gap: 16px;
- margin: 8px 0;
- align-content: center;
-}
-
-.publishSourceSelectorContainer {
- display: flex;
- gap: 16px;
-}
-
-.publishSourceButtonContainer {
- display: flex;
- position: relative;
- align-items: center;
-}
-
-.publishSourceButton {
- opacity: 0.5;
-}
-
-.publishSourceButton.active {
- opacity: 1;
-}
-
-.mediaSourceVisibilityToggle {
- font-size: 0.75em;
- cursor: pointer;
- padding-left: 4px;
-}
-
-.button.button--media-source-selector,
-.button.button--media-source-selector:hover:not(:disabled) {
- background-color: unset;
- box-shadow: none;
- border: unset;
-}
-
-.mediaSourceSelector {
- position: absolute;
- top: 100%;
- transform: translateX(-50%);
- left: 50%;
- display: block;
-}
-
-.hidden {
- display: none;
-}
diff --git a/js/hang-ui/src/shared/components/button/button.css b/js/hang-ui/src/shared/components/button/button.css
deleted file mode 100644
index 32cd648676..0000000000
--- a/js/hang-ui/src/shared/components/button/button.css
+++ /dev/null
@@ -1,29 +0,0 @@
-.button {
- color: var(--color-white);
- cursor: pointer;
- background-color: var(--color-white-alpha-10);
- border: var(--spacing-1) solid var(--color-white-alpha-20);
- border-radius: 50%;
- width: 2.25rem;
- height: 2.25rem;
- transform-origin: center;
- transition: transform 100ms cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-.button:hover:not(:disabled) {
- transform: scale(1.02);
- color: var(--color-white);
- background-color: var(--color-white-alpha-20);
- box-shadow: 0 var(--spacing-2) var(--spacing-8) var(--color-black-alpha-10);
-}
-
-.button:active:not(:disabled) {
- transform: scale(0.98);
- box-shadow: 0 var(--spacing-1) var(--spacing-4) var(--color-black-alpha-8);
-}
-
-.button:disabled {
- opacity: 0.5;
- cursor: default;
- color: var(--color-gray-100);
-}
diff --git a/js/hang-ui/src/shared/components/button/button.tsx b/js/hang-ui/src/shared/components/button/button.tsx
deleted file mode 100644
index 1e217b3319..0000000000
--- a/js/hang-ui/src/shared/components/button/button.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import type { JSX } from "solid-js/jsx-runtime";
-
-/**
- * Props for the shared Button component.
- *
- * @property {('button' | 'submit' | 'reset')} [type] - Button type attribute. Defaults to 'button'.
- * @property {string} [title] - Tooltip/title attribute for the button. Defaults to 'Simple button'.
- * @property {() => void} [onClick] - Click handler function.
- * @property {string} [class] - Additional CSS classes for custom styling.
- * @property {JSX.Element | string} children - Button content (JSX or string). Required.
- * @property {string} [ariaLabel] - Accessible label for screen readers. Falls back to title or children if not provided.
- * @property {boolean} [ariaDisabled] - Accessibility disabled state (for ARIA only).
- * @property {boolean} [disabled] - Disabled state (native HTML attribute).
- * @property {number} [tabIndex] - Tab index for keyboard navigation. Uses browser default (typically 0) if not set.
- */
-export type ButtonProps = {
- type?: "button" | "submit" | "reset";
- title?: string;
- onClick?: () => void;
- class?: string;
- children: JSX.Element | string;
- ariaLabel?: string;
- ariaDisabled?: boolean;
- disabled?: boolean;
- tabIndex?: number;
-};
-
-/**
- * Shared, accessible, and stylable Button component for SolidJS.
- *
- * - Injects a Constructable Stylesheet for style encapsulation (Shadow DOM-friendly).
- * - Supports accessibility attributes and keyboard navigation.
- * - Accepts custom content, classes, and state modifiers (e.g., active, disabled).
- *
- * @param {ButtonProps} props - Button configuration and content.
- * @returns {JSX.Element} A styled, accessible button element.
- */
-export default function Button(props: ButtonProps) {
- return (
-
- {props.children}
-
- );
-}
diff --git a/js/hang-ui/src/shared/components/icon/ban.svg b/js/hang-ui/src/shared/components/icon/ban.svg
deleted file mode 100644
index b8add143cb..0000000000
--- a/js/hang-ui/src/shared/components/icon/ban.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/js/hang-ui/src/shared/components/icon/camera.svg b/js/hang-ui/src/shared/components/icon/camera.svg
deleted file mode 100644
index 6bf1096eb2..0000000000
--- a/js/hang-ui/src/shared/components/icon/camera.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/js/hang-ui/src/shared/components/icon/icon.tsx b/js/hang-ui/src/shared/components/icon/icon.tsx
deleted file mode 100644
index b2dd3bcfaa..0000000000
--- a/js/hang-ui/src/shared/components/icon/icon.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import type { JSX } from "solid-js";
-
-import arrowDown from "./arrow-down.svg?raw";
-import arrowUp from "./arrow-up.svg?raw";
-import audio from "./audio.svg?raw";
-import ban from "./ban.svg?raw";
-import buffer from "./buffer.svg?raw";
-import camera from "./camera.svg?raw";
-import file from "./file.svg?raw";
-import fullscreenEnter from "./fullscreen-enter.svg?raw";
-import fullscreenExit from "./fullscreen-exit.svg?raw";
-import microphone from "./microphone.svg?raw";
-import mute from "./mute.svg?raw";
-import network from "./network.svg?raw";
-import pause from "./pause.svg?raw";
-import play from "./play.svg?raw";
-import screen from "./screen.svg?raw";
-import stats from "./stats.svg?raw";
-import video from "./video.svg?raw";
-import volumeHigh from "./volume-high.svg?raw";
-import volumeLow from "./volume-low.svg?raw";
-import volumeMedium from "./volume-medium.svg?raw";
-
-/**
- * Given the SVG source of an icon, return a JSX.Element
- *
- * @returns JSX.Element
- */
-export function Element(src: string): JSX.Element {
- return ;
-}
-
-// For each icon, export a function that returns a JSX.Element
-const icon = (src: string) => () => Element(src);
-
-export const ArrowDown = icon(arrowDown);
-export const ArrowUp = icon(arrowUp);
-export const Audio = icon(audio);
-export const Ban = icon(ban);
-export const Buffer = icon(buffer);
-export const Camera = icon(camera);
-export const File = icon(file);
-export const FullscreenEnter = icon(fullscreenEnter);
-export const FullscreenExit = icon(fullscreenExit);
-export const Microphone = icon(microphone);
-export const Mute = icon(mute);
-export const Network = icon(network);
-export const Pause = icon(pause);
-export const Play = icon(play);
-export const Screen = icon(screen);
-export const Stats = icon(stats);
-export const Video = icon(video);
-export const VolumeHigh = icon(volumeHigh);
-export const VolumeLow = icon(volumeLow);
-export const VolumeMedium = icon(volumeMedium);
diff --git a/js/hang-ui/src/shared/components/stats/README.md b/js/hang-ui/src/shared/components/stats/README.md
deleted file mode 100644
index 852d12953e..0000000000
--- a/js/hang-ui/src/shared/components/stats/README.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Stats Component
-
-Real-time statistics display for monitoring media streaming performance (network, video, audio, buffer).
-
-## Usage
-
-```tsx
- ctx?.hangWatch()}
-/>
-```
-
-## Props
-
-- **context** - SolidJS context to read from
-- **getElement** - Function that extracts the media element from context
-
-The component displays four metrics: network, video, audio, and buffer statistics.
\ No newline at end of file
diff --git a/js/hang-ui/src/shared/components/stats/__tests__/Stats.test.tsx b/js/hang-ui/src/shared/components/stats/__tests__/Stats.test.tsx
deleted file mode 100644
index a9269f3ac1..0000000000
--- a/js/hang-ui/src/shared/components/stats/__tests__/Stats.test.tsx
+++ /dev/null
@@ -1,225 +0,0 @@
-import { createContext, createSignal } from "solid-js";
-import { render } from "solid-js/web";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { Stats } from "..";
-import type { ProviderProps } from "../types";
-import { createMockProviderProps } from "./utils";
-
-describe("Stats Component", () => {
- let container: HTMLDivElement;
- let dispose: (() => void) | undefined;
-
- beforeEach(() => {
- container = document.createElement("div");
- document.body.appendChild(container);
-
- // Mock fetch to prevent network requests for Icon SVG files
- global.fetch = vi.fn().mockResolvedValue({
- ok: true,
- text: () => Promise.resolve(' '),
- });
- });
-
- afterEach(() => {
- dispose?.();
- dispose = undefined;
- vi.restoreAllMocks();
- });
-
- it("renders stats container", () => {
- const mockProps = createMockProviderProps();
- const TestContext = createContext(mockProps);
-
- dispose = render(
- () => (
-
- context={TestContext} getElement={() => mockProps} />
-
- ),
- container,
- );
-
- const stats = container.querySelector(".stats");
- expect(stats).toBeTruthy();
- });
-
- it("waits for audio and video before rendering", async () => {
- const mockDefault = createMockProviderProps();
- const TestContext = createContext(mockDefault);
-
- dispose = render(() => {
- const [mediaElement, setMediaElement] = createSignal(undefined);
-
- // Set media element after initial render
- setTimeout(() => setMediaElement(createMockProviderProps()), 100);
-
- return (
-
- context={TestContext} getElement={() => mediaElement()} />
-
- );
- }, container);
-
- // Initially no StatsPanel should be rendered
- let panel = container.querySelector(".stats__panel");
- expect(panel).toBeFalsy();
-
- // Wait for setTimeout to trigger
- await new Promise((resolve) => setTimeout(resolve, 150));
-
- // Now panel should be rendered
- panel = container.querySelector(".stats__panel");
- expect(panel).toBeTruthy();
- });
-
- it("only renders when both audio and video are available", async () => {
- const mockDefault = createMockProviderProps();
- const TestContext = createContext(mockDefault);
- const mockWithoutVideo = createMockProviderProps({ video: false });
-
- dispose = render(() => {
- const [mediaElement, setMediaElement] = createSignal | undefined>(mockWithoutVideo);
-
- // Set full props after initial render
- setTimeout(() => setMediaElement(createMockProviderProps()), 100);
-
- return (
-
-
- context={TestContext}
- getElement={() => mediaElement() as ProviderProps | undefined}
- />
-
- );
- }, container);
-
- let panel = container.querySelector(".stats__panel");
-
- // Wait for setTimeout to trigger
- await new Promise((resolve) => setTimeout(resolve, 150));
-
- panel = container.querySelector(".stats__panel");
- expect(panel).toBeTruthy();
- });
-
- it("works with different context types", () => {
- interface CustomContext {
- hangWatch: () => ProviderProps | undefined;
- }
-
- const mockProps = createMockProviderProps();
- const contextValue: CustomContext = {
- hangWatch: () => mockProps,
- };
- const CustomTestContext = createContext(contextValue);
-
- dispose = render(
- () => (
-
- context={CustomTestContext} getElement={(ctx) => ctx?.hangWatch()} />
-
- ),
- container,
- );
-
- const stats = container.querySelector(".stats");
- expect(stats).toBeTruthy();
-
- const panel = container.querySelector(".stats__panel");
- expect(panel).toBeTruthy();
- });
-
- it("provides context value to child components", () => {
- const mockProps = createMockProviderProps();
- const TestContext = createContext(mockProps);
-
- dispose = render(
- () => (
-
- context={TestContext} getElement={() => mockProps} />
-
- ),
- container,
- );
-
- const panel = container.querySelector(".stats__panel");
- expect(panel).toBeTruthy();
- });
-
- it("handles undefined context gracefully", () => {
- const mockDefault = createMockProviderProps();
- const TestContext = createContext(mockDefault);
-
- dispose = render(
- () => (
-
- context={TestContext} getElement={() => undefined} />
-
- ),
- container,
- );
-
- // Should not render panel when element is undefined
- const panel = container.querySelector(".stats__panel");
- expect(panel).toBeFalsy();
- });
-
- it("updates when context changes", async () => {
- const mockDefault = createMockProviderProps();
- const TestContext = createContext(mockDefault);
-
- dispose = render(() => {
- const [element, setElement] = createSignal(undefined);
-
- // Set element after initial render
- setTimeout(() => setElement(createMockProviderProps()), 100);
-
- return (
-
- context={TestContext} getElement={() => element()} />
-
- );
- }, container);
-
- let panel = container.querySelector(".stats__panel");
- expect(panel).toBeFalsy();
-
- // Wait for setTimeout to trigger
- await new Promise((resolve) => setTimeout(resolve, 150));
-
- panel = container.querySelector(".stats__panel");
- expect(panel).toBeTruthy();
- });
-
- it("rerenders when getElement function returns different values", async () => {
- const TestContext = createContext("test");
- const element1 = createMockProviderProps();
- const element2 = createMockProviderProps();
-
- dispose = render(() => {
- const [key, setKey] = createSignal<"element1" | "element2">("element1");
-
- const getElement = (_ctx: string) => {
- return key() === "element1" ? element1 : element2;
- };
-
- // Change key after initial render
- setTimeout(() => setKey("element2"), 100);
-
- return (
-
- context={TestContext} getElement={getElement} />
-
- );
- }, container);
-
- let panel = container.querySelector(".stats__panel");
- expect(panel).toBeTruthy();
-
- // Wait for setTimeout to trigger
- await new Promise((resolve) => setTimeout(resolve, 150));
-
- panel = container.querySelector(".stats__panel");
- expect(panel).toBeTruthy();
- });
-});
diff --git a/js/hang-ui/src/shared/components/stats/__tests__/components/StatsItem.test.tsx b/js/hang-ui/src/shared/components/stats/__tests__/components/StatsItem.test.tsx
deleted file mode 100644
index e97d3ae3f8..0000000000
--- a/js/hang-ui/src/shared/components/stats/__tests__/components/StatsItem.test.tsx
+++ /dev/null
@@ -1,363 +0,0 @@
-import { createSignal } from "solid-js";
-import { render } from "solid-js/web";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import * as Icon from "../../../icon/icon";
-import { StatsItem } from "../../components/StatsItem";
-import type { BaseProvider } from "../../providers/base";
-import * as registry from "../../providers/registry";
-import type { ProviderContext, ProviderProps } from "../../types";
-import { createMockProviderProps } from "../utils";
-
-vi.mock("../../providers/registry", () => ({
- getStatsInformationProvider: vi.fn(),
-}));
-
-describe("StatsItem", () => {
- let container: HTMLDivElement;
- let dispose: (() => void) | undefined;
- let mockAudioVideo: ProviderProps;
-
- beforeEach(() => {
- container = document.createElement("div");
- document.body.appendChild(container);
- mockAudioVideo = createMockProviderProps();
-
- // Mock fetch to prevent network requests for Icon SVG files
- global.fetch = vi.fn().mockResolvedValue({
- ok: true,
- text: () => Promise.resolve(' '),
- });
- });
-
- afterEach(() => {
- dispose?.();
- dispose = undefined;
- document.body.removeChild(container);
- vi.clearAllMocks();
- });
-
- it("renders with correct base structure", () => {
- vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined);
-
- dispose = render(
- () => (
- }
- audio={mockAudioVideo.audio}
- video={mockAudioVideo.video}
- />
- ),
- container,
- );
-
- const item = container.querySelector(".stats__item");
- expect(item).toBeTruthy();
- expect(item?.classList.contains("stats__item--network")).toBe(true);
- });
-
- it("renders icon wrapper with SVG content", () => {
- vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined);
-
- dispose = render(() => {
- const testSvg = (
-
-
-
- );
-
- return (
-
- );
- }, container);
-
- const iconWrapper = container.querySelector(".stats__icon-wrapper");
- expect(iconWrapper).toBeTruthy();
-
- const icon = container.querySelector("svg[data-testid='test-icon']");
- expect(icon).toBeTruthy();
- });
-
- it("renders item detail with icon text", () => {
- vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined);
-
- dispose = render(
- () => (
- }
- audio={mockAudioVideo.audio}
- video={mockAudioVideo.video}
- />
- ),
- container,
- );
-
- const iconText = container.querySelector(".stats__item-title");
- expect(iconText?.textContent).toBe("audio");
- });
-
- it("displays N/A when no provider is available", () => {
- vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined);
-
- dispose = render(
- () => (
- }
- audio={mockAudioVideo.audio}
- video={mockAudioVideo.video}
- />
- ),
- container,
- );
-
- const dataDisplay = container.querySelector(".stats__item-data");
- expect(dataDisplay?.textContent).toBe("N/A");
- });
-
- it("initializes provider with audio and video props", () => {
- const mockProvider: Pick = {
- setup: vi.fn(),
- cleanup: vi.fn(),
- };
-
- const MockProviderClass = vi.fn(() => mockProvider) as unknown as ReturnType<
- typeof registry.getStatsInformationProvider
- >;
- vi.mocked(registry.getStatsInformationProvider).mockReturnValue(MockProviderClass);
-
- dispose = render(
- () => (
- }
- audio={mockAudioVideo.audio}
- video={mockAudioVideo.video}
- />
- ),
- container,
- );
-
- expect(MockProviderClass).toHaveBeenCalledWith(mockAudioVideo);
- });
-
- it("calls provider setup with setDisplayData callback", () => {
- const mockProvider: Pick = {
- setup: vi.fn(),
- cleanup: vi.fn(),
- };
-
- const MockProviderClass = vi.fn(() => mockProvider) as unknown as ReturnType<
- typeof registry.getStatsInformationProvider
- >;
- vi.mocked(registry.getStatsInformationProvider).mockReturnValue(MockProviderClass);
-
- dispose = render(
- () => (
- }
- audio={mockAudioVideo.audio}
- video={mockAudioVideo.video}
- />
- ),
- container,
- );
-
- expect(mockProvider.setup).toHaveBeenCalled();
-
- const setupCall = vi.mocked(mockProvider.setup).mock.calls[0][0] as ProviderContext;
- expect(setupCall.setDisplayData).toBeDefined();
- expect(typeof setupCall.setDisplayData).toBe("function");
- });
-
- it("updates display data when provider calls setDisplayData", () => {
- let capturedSetDisplayData: ((data: string) => void) | undefined;
-
- const mockProvider: Pick = {
- setup: vi.fn((context: ProviderContext) => {
- capturedSetDisplayData = context.setDisplayData;
- }),
- cleanup: vi.fn(),
- };
-
- const MockProviderClass = vi.fn(() => mockProvider) as unknown as ReturnType<
- typeof registry.getStatsInformationProvider
- >;
- vi.mocked(registry.getStatsInformationProvider).mockReturnValue(MockProviderClass);
-
- dispose = render(
- () => (
- }
- audio={mockAudioVideo.audio}
- video={mockAudioVideo.video}
- />
- ),
- container,
- );
-
- expect(capturedSetDisplayData).toBeDefined();
-
- capturedSetDisplayData?.("42 kbps");
-
- const dataDisplay = container.querySelector(".stats__item-data");
- expect(dataDisplay?.textContent).toBe("42 kbps");
- });
-
- it("renders correct class for each icon type", () => {
- vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined);
-
- const statsProvider = [
- { name: "network", Icon: Icon.Network },
- { name: "video", Icon: Icon.Video },
- { name: "audio", Icon: Icon.Audio },
- { name: "buffer", Icon: Icon.Buffer },
- ] as const;
-
- statsProvider.forEach((provider) => {
- const testContainer = document.createElement("div");
- document.body.appendChild(testContainer);
-
- const testDispose = render(
- () => (
- }
- audio={mockAudioVideo.audio}
- video={mockAudioVideo.video}
- />
- ),
- testContainer,
- );
-
- const item = testContainer.querySelector(".stats__item");
- expect(item?.classList.contains(`stats__item--${provider.name}`)).toBe(true);
-
- testDispose();
- document.body.removeChild(testContainer);
- });
- });
-
- it("cleans up previous provider before initializing new one", async () => {
- const mockProvider1: Pick = {
- setup: vi.fn(),
- cleanup: vi.fn(),
- };
-
- const mockProvider2: Pick = {
- setup: vi.fn(),
- cleanup: vi.fn(),
- };
-
- const MockProviderClass1 = vi.fn(() => mockProvider1) as unknown as ReturnType<
- typeof registry.getStatsInformationProvider
- >;
- const MockProviderClass2 = vi.fn(() => mockProvider2) as unknown as ReturnType<
- typeof registry.getStatsInformationProvider
- >;
-
- let _callCount = 0;
- vi.mocked(registry.getStatsInformationProvider).mockImplementation(() => {
- if (_callCount === 0) {
- _callCount++;
- return MockProviderClass1;
- }
- return MockProviderClass2;
- });
-
- dispose = render(() => {
- const [statProvider, setStatProvider] = createSignal<"network" | "video">("network");
-
- // Switch provider after initial render
- setTimeout(() => {
- _callCount = 0;
- vi.mocked(registry.getStatsInformationProvider).mockReturnValue(MockProviderClass2);
- setStatProvider("video");
- }, 0);
-
- return (
- : }
- audio={mockAudioVideo.audio}
- video={mockAudioVideo.video}
- />
- );
- }, container);
-
- expect(mockProvider1.setup).toHaveBeenCalled();
-
- // Wait for setTimeout
- await new Promise((resolve) => setTimeout(resolve, 10));
-
- expect(mockProvider1.cleanup).toHaveBeenCalled();
- expect(mockProvider2.setup).toHaveBeenCalled();
- });
-
- it("maintains correct DOM hierarchy", () => {
- vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined);
-
- dispose = render(
- () => (
- }
- audio={mockAudioVideo.audio}
- video={mockAudioVideo.video}
- />
- ),
- container,
- );
-
- const item = container.querySelector(".stats__item");
- expect(item?.children.length).toBe(2);
-
- const iconWrapper = item?.querySelector(".stats__icon-wrapper");
- expect(iconWrapper).toBeTruthy();
- expect(iconWrapper?.children.length).toBe(1);
-
- const detail = item?.querySelector(".stats__item-detail");
- expect(detail).toBeTruthy();
- expect(detail?.children.length).toBe(2);
-
- expect(detail?.querySelector(".stats__item-title")).toBeTruthy();
- expect(detail?.querySelector(".stats__item-data")).toBeTruthy();
- });
-
- it("calls getStatsInformationProvider with correct statProvider", () => {
- vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined);
-
- dispose = render(
- () => (
- }
- audio={mockAudioVideo.audio}
- video={mockAudioVideo.video}
- />
- ),
- container,
- );
-
- expect(registry.getStatsInformationProvider).toHaveBeenCalledWith("buffer");
- });
-});
diff --git a/js/hang-ui/src/shared/components/stats/__tests__/components/StatsPanel.test.tsx b/js/hang-ui/src/shared/components/stats/__tests__/components/StatsPanel.test.tsx
deleted file mode 100644
index 09a44c71a7..0000000000
--- a/js/hang-ui/src/shared/components/stats/__tests__/components/StatsPanel.test.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { render } from "solid-js/web";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { StatsPanel, statsDetailItems } from "../../components/StatsPanel";
-import type { ProviderProps } from "../../types";
-import { createMockProviderProps } from "../utils";
-
-describe("StatsPanel", () => {
- let container: HTMLDivElement;
- let dispose: (() => void) | undefined;
- let mockAudioVideo: ProviderProps;
-
- beforeEach(() => {
- container = document.createElement("div");
- document.body.appendChild(container);
- mockAudioVideo = createMockProviderProps();
-
- // Mock fetch to prevent network requests for Icon SVG files
- global.fetch = vi.fn().mockResolvedValue({
- ok: true,
- text: () => Promise.resolve(' '),
- });
- });
-
- afterEach(() => {
- dispose?.();
- dispose = undefined;
- document.body.removeChild(container);
- vi.restoreAllMocks();
- });
-
- it("renders with correct base class", () => {
- dispose = render(() => , container);
-
- const panel = container.querySelector(".stats__panel");
- expect(panel).toBeTruthy();
- });
-
- it("renders all metric items", () => {
- dispose = render(() => , container);
-
- const items = container.querySelectorAll(".stats__item");
- expect(items.length).toBe(statsDetailItems.length);
- });
-
- it("renders items with correct icon types", () => {
- const expectedIcons = ["network", "video", "audio", "buffer"];
- dispose = render(() => , container);
-
- const items = container.querySelectorAll(".stats__item");
- items.forEach((item, index) => {
- expect(item.classList.contains(`stats__item--${expectedIcons[index]}`)).toBe(true);
- });
- });
-
- it("renders each item with icon wrapper", () => {
- dispose = render(() => , container);
-
- const wrappers = container.querySelectorAll(".stats__icon-wrapper");
- expect(wrappers.length).toBe(4);
- });
-
- it("renders each item with detail section", () => {
- dispose = render(() => , container);
-
- const details = container.querySelectorAll(".stats__item-detail");
- expect(details.length).toBe(4);
- });
-
- it("maintains correct DOM structure", () => {
- dispose = render(() => , container);
-
- const panel = container.querySelector(".stats__panel");
- const items = panel?.querySelectorAll(".stats__item");
-
- expect(panel?.children.length).toBe(4);
- items?.forEach((item) => {
- expect(item.querySelector(".stats__icon-wrapper")).toBeTruthy();
- expect(item.querySelector(".stats__item-detail")).toBeTruthy();
- });
- });
-});
diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/audio.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/audio.test.ts
deleted file mode 100644
index 081f542efe..0000000000
--- a/js/hang-ui/src/shared/components/stats/__tests__/providers/audio.test.ts
+++ /dev/null
@@ -1,255 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { AudioProvider } from "../../providers/audio";
-import type { AudioConfig, AudioSource, AudioStats, ProviderContext, ProviderProps } from "../../types";
-import { createMockProviderProps } from "../utils";
-
-declare global {
- var __advanceTime: (ms: number) => void;
-}
-
-describe("AudioProvider", () => {
- let provider: AudioProvider;
- let context: ProviderContext;
- let setDisplayData: ReturnType;
- let intervalCallback: ((interval: number) => void) | null = null;
- let originalWindow: typeof window;
- let originalPerformance: typeof performance;
- let mockClearInterval: ReturnType;
-
- beforeEach(() => {
- originalWindow = global.window;
- originalPerformance = global.performance as unknown as Performance;
- setDisplayData = vi.fn();
- context = { setDisplayData };
- intervalCallback = null;
-
- // Mock window functions
- const mockSetInterval = vi.fn((callback: () => void) => {
- intervalCallback = callback as unknown as (interval: number) => void;
- return 1;
- });
- mockClearInterval = vi.fn();
-
- global.window = {
- setInterval: mockSetInterval,
- clearInterval: mockClearInterval,
- } as unknown as typeof window;
-
- // Mock performance.now()
- let mockTime = 0;
- global.performance = {
- now: vi.fn(() => mockTime),
- } as unknown as Performance;
-
- // Helper to advance mock time
- global.__advanceTime = (ms: number) => {
- mockTime += ms;
- };
- });
-
- afterEach(() => {
- provider?.cleanup();
- global.window = originalWindow;
- global.performance = originalPerformance as unknown as Performance;
- });
-
- it("should display N/A when audio source is not available", () => {
- const props: ProviderProps = {};
- provider = new AudioProvider(props);
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("N/A");
- });
-
- it("should display audio config with stats placeholder on first call", () => {
- const audioConfig: AudioConfig = {
- sampleRate: 48000,
- numberOfChannels: 2,
- bitrate: 128000,
- codec: "opus",
- };
-
- const stats: AudioStats = { bytesReceived: 0 };
-
- const audio: AudioSource = {
- source: {
- active: {
- peek: () => "audio",
- subscribe: vi.fn(() => vi.fn()),
- },
- config: {
- peek: () => audioConfig,
- subscribe: vi.fn(() => vi.fn()),
- },
- stats: {
- peek: () => stats,
- subscribe: vi.fn(() => vi.fn()),
- },
- },
- };
-
- const props: ProviderProps = { audio };
- provider = new AudioProvider(props);
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("48.0kHz\n2ch\nN/A\nopus");
- });
-
- it("should calculate bitrate from bytesReceived delta", () => {
- const audioConfig: AudioConfig = {
- sampleRate: 48000,
- numberOfChannels: 2,
- bitrate: 128000,
- codec: "opus",
- };
-
- const peekFn = vi.fn(() => ({ bytesReceived: 0 }) as AudioStats);
-
- const audio: AudioSource = {
- source: {
- active: {
- peek: () => "audio",
- subscribe: vi.fn(() => vi.fn()),
- },
- config: {
- peek: () => audioConfig,
- subscribe: vi.fn(() => vi.fn()),
- },
- stats: {
- peek: peekFn,
- subscribe: vi.fn(() => vi.fn()),
- },
- },
- };
-
- const props: ProviderProps = { audio };
- provider = new AudioProvider(props);
-
- // First call - start with bytes already flowing
- peekFn.mockReturnValue({ bytesReceived: 5000 } as AudioStats);
- provider.setup(context);
- expect(setDisplayData).toHaveBeenCalledWith("48.0kHz\n2ch\nN/A\nopus");
-
- // Simulate bytesReceived increasing: 6250 bytes delta = 200 kbps at 250ms interval
- // (6250 bytes * 8 bits/byte * 4) / 1000 = 200 kbps
- peekFn.mockReturnValue({ bytesReceived: 11250 } as AudioStats);
- global.__advanceTime(250);
- intervalCallback?.(250);
- expect(setDisplayData).toHaveBeenNthCalledWith(2, "48.0kHz\n2ch\n200kbps\nopus");
-
- // Increase bytes more: delta 3125 = 100 kbps
- peekFn.mockReturnValue({ bytesReceived: 14375 } as AudioStats);
- global.__advanceTime(250);
- intervalCallback?.(250);
- expect(setDisplayData).toHaveBeenNthCalledWith(3, "48.0kHz\n2ch\n100kbps\nopus");
- });
-
- it("should display N/A when active or config is missing", () => {
- const audio: AudioSource = {
- source: {
- active: {
- peek: () => undefined,
- subscribe: vi.fn(() => vi.fn()),
- },
- config: {
- peek: () => undefined,
- subscribe: vi.fn(() => vi.fn()),
- },
- stats: {
- peek: () => ({ bytesReceived: 0 }),
- subscribe: vi.fn(() => vi.fn()),
- },
- },
- };
-
- const props: ProviderProps = { audio };
- provider = new AudioProvider(props);
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("N/A");
- });
-
- it("should handle mono audio", () => {
- const audioConfig: AudioConfig = {
- sampleRate: 44100,
- numberOfChannels: 1,
- bitrate: 128000,
- codec: "opus",
- };
-
- const audio: AudioSource = {
- source: {
- active: {
- peek: () => "audio",
- subscribe: vi.fn(() => vi.fn()),
- },
- config: {
- peek: () => audioConfig,
- subscribe: vi.fn(() => vi.fn()),
- },
- stats: {
- peek: () => ({ bytesReceived: 0 }),
- subscribe: vi.fn(() => vi.fn()),
- },
- },
- };
-
- const props: ProviderProps = { audio };
- provider = new AudioProvider(props);
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("44.1kHz\n1ch\nN/A\nopus");
- });
-
- it("should format Mbps for high bitrates", () => {
- const audioConfig: AudioConfig = {
- sampleRate: 48000,
- numberOfChannels: 2,
- bitrate: 128000,
- codec: "opus",
- };
-
- const peekFn = vi.fn(() => ({ bytesReceived: 0 }) as AudioStats);
-
- const audio: AudioSource = {
- source: {
- active: {
- peek: () => "audio",
- subscribe: vi.fn(() => vi.fn()),
- },
- config: {
- peek: () => audioConfig,
- subscribe: vi.fn(() => vi.fn()),
- },
- stats: {
- peek: peekFn,
- subscribe: vi.fn(() => vi.fn()),
- },
- },
- };
-
- const props: ProviderProps = { audio };
- provider = new AudioProvider(props);
-
- // First call - display audio config with stats placeholder on first call
- peekFn.mockReturnValue({ bytesReceived: 48000 } as AudioStats);
- provider.setup(context);
- // Simulate 5 Mbps: 156250 bytes delta = 5000000 bits/s = 5 Mbps
- peekFn.mockReturnValue({ bytesReceived: 204250 } as AudioStats);
- global.__advanceTime(250);
- intervalCallback?.(250);
-
- expect(setDisplayData).toHaveBeenNthCalledWith(2, "48.0kHz\n2ch\n5.0Mbps\nopus");
- });
-
- it("should cleanup interval on dispose", () => {
- const props: ProviderProps = createMockProviderProps({ video: false });
- provider = new AudioProvider(props);
- provider.setup(context);
-
- provider.cleanup();
-
- expect(mockClearInterval).toHaveBeenCalledTimes(1);
- expect(mockClearInterval).toHaveBeenCalledWith(1);
- });
-});
diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/base.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/base.test.ts
deleted file mode 100644
index c70a27f27c..0000000000
--- a/js/hang-ui/src/shared/components/stats/__tests__/providers/base.test.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { BaseProvider } from "../../providers/base";
-import type { ProviderContext, ProviderProps } from "../../types";
-
-class TestProvider extends BaseProvider {
- public setupCalled = false;
- public setupContext: ProviderContext | undefined;
- public cleanupCalled = false;
-
- setup(context: ProviderContext): void {
- this.setupCalled = true;
- this.setupContext = context;
- }
-
- cleanup(): void {
- this.cleanupCalled = true;
- super.cleanup();
- }
-}
-
-describe("BaseProvider", () => {
- let provider: TestProvider;
- let context: ProviderContext;
-
- beforeEach(() => {
- const props: ProviderProps = {};
- provider = new TestProvider(props);
- context = {
- setDisplayData: vi.fn(),
- };
- });
-
- it("should initialize with props", () => {
- const props: ProviderProps = { audio: undefined, video: undefined };
- const testProvider = new TestProvider(props);
- expect(testProvider).toBeDefined();
- });
-
- it("should call setup method", () => {
- provider.setup(context);
- expect(provider.setupCalled).toBe(true);
- expect(provider.setupContext).toEqual(context);
- });
-
- it("should cleanup", () => {
- provider.setup(context);
- expect(provider.cleanupCalled).toBe(false);
- provider.cleanup();
- expect(provider.cleanupCalled).toBe(true);
- expect(provider.setupCalled).toBe(true);
- });
-
- it("should have setup and cleanup methods", () => {
- expect(provider.setup).toBeDefined();
- expect(provider.cleanup).toBeDefined();
- expect(typeof provider.setup).toBe("function");
- expect(typeof provider.cleanup).toBe("function");
- });
-});
diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/buffer.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/buffer.test.ts
deleted file mode 100644
index 8c3b25dd7b..0000000000
--- a/js/hang-ui/src/shared/components/stats/__tests__/providers/buffer.test.ts
+++ /dev/null
@@ -1,286 +0,0 @@
-import type { Getter } from "@moq/signals";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { BufferProvider } from "../../providers/buffer";
-import type {
- BufferStatus,
- ProviderContext,
- ProviderProps,
- SyncStatus,
- VideoResolution,
- VideoSource,
- VideoStats,
-} from "../../types";
-
-describe("BufferProvider", () => {
- let provider: BufferProvider;
- let context: ProviderContext;
- let setDisplayData: ReturnType;
-
- /**
- * Helper to create a complete VideoSource mock with all required properties
- */
- const createVideoSourceMock = (overrides?: {
- display?: Partial>;
- syncStatus?: Partial>;
- bufferStatus?: Partial>;
- latency?: Partial>;
- stats?: Partial>;
- }): VideoSource => {
- const unsubscribe = vi.fn();
- const changedCallbacks: Map void> = new Map();
-
- const createSignalMock = (key: string, peek: () => T) => {
- const callbacks: Array<() => void> = [];
- return {
- peek,
- changed: vi.fn((fn: () => void) => {
- callbacks.push(fn);
- // Store the callback so we can trigger it later if needed
- changedCallbacks.set(key, () => {
- callbacks.forEach((cb) => {
- cb();
- });
- });
- return unsubscribe;
- }),
- subscribe: vi.fn(() => unsubscribe),
- };
- };
-
- const displaySignal = {
- ...createSignalMock("display", () => ({ width: 1920, height: 1080 })),
- ...overrides?.display,
- };
- const syncStatusSignal = {
- ...createSignalMock("syncStatus", () => ({ state: "ready" as const })),
- ...overrides?.syncStatus,
- };
- const bufferStatusSignal = {
- ...createSignalMock("bufferStatus", () => ({ state: "filled" as const })),
- ...overrides?.bufferStatus,
- };
- const latencySignal = {
- ...createSignalMock("latency", () => 100),
- ...overrides?.latency,
- };
- const statsSignal = {
- ...createSignalMock("stats", () => ({ frameCount: 0, timestamp: 0, bytesReceived: 0 }) as VideoStats),
- ...overrides?.stats,
- };
-
- return {
- source: {
- display: displaySignal,
- syncStatus: syncStatusSignal,
- bufferStatus: bufferStatusSignal,
- latency: latencySignal,
- stats: statsSignal,
- },
- };
- };
-
- beforeEach(() => {
- setDisplayData = vi.fn();
- context = { setDisplayData };
- });
-
- it("should display N/A when video source is not available", () => {
- const props: ProviderProps = {};
- provider = new BufferProvider(props);
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("N/A");
- });
-
- it("should calculate buffer percentage from sync status", async () => {
- const unsubscribe = vi.fn();
- const video = createVideoSourceMock({
- syncStatus: {
- peek: () => ({
- state: "wait" as const,
- bufferDuration: 500,
- }),
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- bufferStatus: {
- peek: () => ({ state: "empty" as const }),
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- latency: {
- peek: () => 1000,
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- });
-
- const props: ProviderProps = { video };
- provider = new BufferProvider(props);
- provider.setup(context);
-
- // Wait for the effect to run
- await Promise.resolve();
-
- expect(setDisplayData).toHaveBeenCalledWith("50%\n1000ms");
- });
-
- it("should display 100% when buffer is filled", async () => {
- const unsubscribe = vi.fn();
- const video = createVideoSourceMock({
- syncStatus: {
- peek: () => undefined,
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- bufferStatus: {
- peek: () => ({ state: "filled" as const }),
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- latency: {
- peek: () => 500,
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- });
-
- const props: ProviderProps = { video };
- provider = new BufferProvider(props);
- provider.setup(context);
-
- // Wait for the effect to run
- await Promise.resolve();
-
- expect(setDisplayData).toHaveBeenCalledWith("100%\n500ms");
- });
-
- it("should display 0% when buffer is empty", async () => {
- const unsubscribe = vi.fn();
- const video = createVideoSourceMock({
- syncStatus: {
- peek: () => undefined,
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- bufferStatus: {
- peek: () => ({ state: "empty" as const }),
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- latency: {
- peek: () => 1000,
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- });
-
- const props: ProviderProps = { video };
- provider = new BufferProvider(props);
- provider.setup(context);
-
- // Wait for the effect to run
- await Promise.resolve();
-
- expect(setDisplayData).toHaveBeenCalledWith("0%\n1000ms");
- });
-
- it("should cap buffer percentage at 100%", async () => {
- const unsubscribe = vi.fn();
- const video = createVideoSourceMock({
- syncStatus: {
- peek: () => ({
- state: "wait" as const,
- bufferDuration: 2000,
- }),
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- bufferStatus: {
- peek: () => ({ state: "empty" as const }),
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- latency: {
- peek: () => 1000,
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- });
-
- const props: ProviderProps = { video };
- provider = new BufferProvider(props);
- provider.setup(context);
-
- // Wait for the effect to run
- await Promise.resolve();
-
- expect(setDisplayData).toHaveBeenCalledWith("100%\n1000ms");
- });
-
- it("should display N/A when latency is not available", async () => {
- const unsubscribe = vi.fn();
- const video = createVideoSourceMock({
- syncStatus: {
- peek: () => ({
- state: "wait" as const,
- bufferDuration: 500,
- }),
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- bufferStatus: {
- peek: () => ({ state: "empty" as const }),
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- latency: {
- peek: () => undefined,
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- });
-
- const props: ProviderProps = { video };
- provider = new BufferProvider(props);
- provider.setup(context);
-
- // Wait for the effect to run
- await Promise.resolve();
-
- expect(setDisplayData).toHaveBeenCalledWith("0%\nN/A");
- });
-
- it("should calculate percentage correctly with decimal values", async () => {
- const unsubscribe = vi.fn();
- const video = createVideoSourceMock({
- syncStatus: {
- peek: () => ({
- state: "wait" as const,
- bufferDuration: 333,
- }),
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- bufferStatus: {
- peek: () => ({ state: "empty" as const }),
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- latency: {
- peek: () => 1000,
- changed: vi.fn(() => unsubscribe),
- subscribe: vi.fn(() => unsubscribe),
- },
- });
-
- const props: ProviderProps = { video };
- provider = new BufferProvider(props);
- provider.setup(context);
-
- // Wait for the effect to run
- await Promise.resolve();
-
- expect(setDisplayData).toHaveBeenCalledWith("33%\n1000ms");
- });
-});
diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/network.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/network.test.ts
deleted file mode 100644
index 1aed84d59c..0000000000
--- a/js/hang-ui/src/shared/components/stats/__tests__/providers/network.test.ts
+++ /dev/null
@@ -1,309 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { NetworkProvider } from "../../providers/network";
-import type { ProviderContext, ProviderProps } from "../../types";
-
-interface MockConnection {
- effectiveType?: "slow-2g" | "2g" | "3g" | "4g";
- downlinkMax?: number;
- downlink?: number;
- rtt?: number;
- saveData?: boolean;
- addEventListener?: (type: string, listener: () => void) => void;
- removeEventListener?: (type: string, listener: () => void) => void;
-}
-
-const mockNavigator: { onLine: boolean; connection?: MockConnection } = {
- onLine: true,
- connection: undefined,
-};
-
-describe("NetworkProvider", () => {
- let provider: NetworkProvider;
- let context: ProviderContext;
- let setDisplayData: ReturnType;
-
- beforeEach(() => {
- setDisplayData = vi.fn();
- context = { setDisplayData };
-
- Object.defineProperty(window, "navigator", {
- value: mockNavigator,
- writable: true,
- configurable: true,
- });
-
- vi.useFakeTimers();
- });
-
- afterEach(() => {
- provider?.cleanup();
- vi.useRealTimers();
- vi.clearAllMocks();
- });
-
- it("should display N/A initially when no connection info", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.connection = undefined;
- mockNavigator.onLine = true;
-
- provider.setup(context);
- expect(setDisplayData).toHaveBeenCalledWith("N/A");
- });
-
- it("should display offline status when browser is offline", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = false;
- mockNavigator.connection = {
- effectiveType: "4g" as const,
- };
-
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("offline");
- });
-
- it("should display effective connection type", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = {
- effectiveType: "4g" as const,
- };
-
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("4G");
- });
-
- it("should map all effective connection types", () => {
- const effectiveTypes: Array<["slow-2g", string] | ["2g", string] | ["3g", string] | ["4g", string]> = [
- ["slow-2g", "Slow-2G"],
- ["2g", "2G"],
- ["3g", "3G"],
- ["4g", "4G"],
- ];
-
- for (const [type, expected] of effectiveTypes) {
- setDisplayData.mockClear();
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = { effectiveType: type };
-
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith(expected);
- provider.cleanup();
- }
- });
-
- it("should display bandwidth in Gbps", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = {
- effectiveType: "4g" as const,
- downlink: 5000,
- };
-
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("4G\n5.0Gbps");
- });
-
- it("should display bandwidth in Mbps", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = {
- effectiveType: "4g" as const,
- downlink: 50,
- };
-
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("4G\n50.0Mbps");
- });
-
- it("should display bandwidth in Kbps", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = {
- effectiveType: "3g" as const,
- downlink: 0.5,
- };
-
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("3G\n500Kbps");
- });
-
- it("should display latency in milliseconds", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = {
- effectiveType: "4g" as const,
- rtt: 50,
- };
-
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("4G\n50ms");
- });
-
- it("should display save-data status when enabled", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = {
- effectiveType: "4g" as const,
- saveData: true,
- };
-
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("4G\nSave-Data");
- });
-
- it("should combine all network metrics", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = {
- effectiveType: "4g" as const,
- downlink: 50,
- rtt: 45,
- saveData: false,
- };
-
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("4G\n50.0Mbps\n45ms");
- });
-
- it("should update display on online event", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = {
- effectiveType: "4g" as const,
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- };
-
- provider.setup(context);
- expect(setDisplayData).toHaveBeenLastCalledWith("4G");
-
- setDisplayData.mockClear();
- mockNavigator.onLine = false;
-
- window.dispatchEvent(new Event("offline"));
-
- expect(setDisplayData).toHaveBeenCalledWith("offline");
- });
-
- it("should update display on offline event", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = { effectiveType: "4g" as const };
-
- provider.setup(context);
- expect(setDisplayData).toHaveBeenLastCalledWith("4G");
-
- setDisplayData.mockClear();
- mockNavigator.onLine = false;
-
- window.dispatchEvent(new Event("offline"));
-
- expect(setDisplayData).toHaveBeenCalledWith("offline");
- });
-
- it("should ignore zero or negative bandwidth", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = {
- effectiveType: "4g" as const,
- downlink: 0,
- };
-
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("4G");
- });
-
- it("should ignore zero or negative latency", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = {
- effectiveType: "4g" as const,
- rtt: 0,
- };
-
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("4G");
- });
-
- it("should cleanup event listeners", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = { effectiveType: "4g" as const };
-
- provider.setup(context);
-
- const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
- provider.cleanup();
-
- expect(removeEventListenerSpy).toHaveBeenCalledWith("online", expect.any(Function));
- expect(removeEventListenerSpy).toHaveBeenCalledWith("offline", expect.any(Function));
- });
-
- it("should update periodically", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
- mockNavigator.connection = {
- effectiveType: "4g" as const,
- downlink: 50,
- };
-
- provider.setup(context);
- setDisplayData.mockClear();
-
- vi.advanceTimersByTime(100);
-
- expect(setDisplayData).toHaveBeenCalled();
- });
-
- it("should prefer mozilla and webkit connection fallbacks", () => {
- const props: ProviderProps = {};
- provider = new NetworkProvider(props);
- mockNavigator.onLine = true;
-
- const mozConnection = {
- effectiveType: "3g" as const,
- };
-
- Object.defineProperty(window, "navigator", {
- value: {
- onLine: true,
- connection: undefined,
- mozConnection,
- },
- writable: true,
- configurable: true,
- });
-
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("3G");
- });
-});
diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/registry.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/registry.test.ts
deleted file mode 100644
index 180c997f31..0000000000
--- a/js/hang-ui/src/shared/components/stats/__tests__/providers/registry.test.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { AudioProvider } from "../../providers/audio";
-import { BufferProvider } from "../../providers/buffer";
-import { NetworkProvider } from "../../providers/network";
-import { getStatsInformationProvider, providers } from "../../providers/registry";
-import { VideoProvider } from "../../providers/video";
-import type { KnownStatsProviders } from "../../types";
-
-describe("Registry", () => {
- it("should have all required providers registered", () => {
- const expectedProviders: KnownStatsProviders[] = ["network", "video", "audio", "buffer"];
-
- for (const statProvider of expectedProviders) {
- expect(providers[statProvider]).toBeDefined();
- }
- });
-
- it("should map video icon to VideoProvider", () => {
- expect(providers.video).toBe(VideoProvider);
- });
-
- it("should map audio icon to AudioProvider", () => {
- expect(providers.audio).toBe(AudioProvider);
- });
-
- it("should map buffer icon to BufferProvider", () => {
- expect(providers.buffer).toBe(BufferProvider);
- });
-
- it("should map network icon to NetworkProvider", () => {
- expect(providers.network).toBe(NetworkProvider);
- });
-
- it("should return correct provider class with getStatsInformationProvider", () => {
- expect(getStatsInformationProvider("video")).toBe(VideoProvider);
- expect(getStatsInformationProvider("audio")).toBe(AudioProvider);
- expect(getStatsInformationProvider("buffer")).toBe(BufferProvider);
- expect(getStatsInformationProvider("network")).toBe(NetworkProvider);
- });
-
- it("should return undefined for unknown icon", () => {
- expect(getStatsInformationProvider("unknown" as KnownStatsProviders)).toBeUndefined();
- });
-
- it("should instantiate providers correctly", () => {
- const providersList: KnownStatsProviders[] = ["network", "video", "audio", "buffer"];
-
- for (const statProvider of providersList) {
- const ProviderClass = getStatsInformationProvider(statProvider);
- expect(ProviderClass).toBeDefined();
-
- if (ProviderClass) {
- const instance = new ProviderClass({});
- expect(instance).toBeDefined();
- expect(instance.setup).toBeDefined();
- expect(instance.cleanup).toBeDefined();
- }
- }
- });
-});
diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/video.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/video.test.ts
deleted file mode 100644
index ed8252a68a..0000000000
--- a/js/hang-ui/src/shared/components/stats/__tests__/providers/video.test.ts
+++ /dev/null
@@ -1,181 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { VideoProvider } from "../../providers/video";
-import type { ProviderContext, ProviderProps, VideoSource, VideoStats } from "../../types";
-import { createMockProviderProps } from "../utils";
-
-declare global {
- var __advanceTime: (ms: number) => void;
-}
-
-describe("VideoProvider", () => {
- let provider: VideoProvider;
- let context: ProviderContext;
- let setDisplayData: ReturnType;
- let intervalCallback: ((interval: number) => void) | null = null;
-
- beforeEach(() => {
- setDisplayData = vi.fn();
- context = { setDisplayData };
- intervalCallback = null;
-
- // Mock window functions
- const mockSetInterval = vi.fn((callback: (interval: number) => void) => {
- intervalCallback = callback;
- return 1 as unknown as NodeJS.Timeout;
- });
-
- const mockClearInterval = vi.fn();
-
- global.window = {
- setInterval: mockSetInterval,
- clearInterval: mockClearInterval,
- } as unknown as typeof window;
-
- // Mock performance.now()
- let mockTime = 0;
- global.performance = {
- now: vi.fn(() => mockTime),
- } as unknown as Performance;
-
- // Helper to advance mock time
- global.__advanceTime = (ms: number) => {
- mockTime += ms;
- };
- });
-
- afterEach(() => {
- provider?.cleanup();
- });
-
- it("should display N/A when video source is not available", () => {
- const props: ProviderProps = {};
- provider = new VideoProvider(props);
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("N/A");
- });
-
- it("should setup interval for display updates", () => {
- const mockProps = createMockProviderProps({ audio: false });
- const video = mockProps.video as VideoSource;
-
- const props: ProviderProps = { video };
- provider = new VideoProvider(props);
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalled();
- });
-
- it("should display video resolution with stats placeholder on first call", () => {
- const mockProps = createMockProviderProps({ audio: false });
- const video = mockProps.video as VideoSource;
-
- const props: ProviderProps = { video };
- provider = new VideoProvider(props);
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("1920x1080\nN/A\nN/A");
- });
-
- it("should calculate FPS from frame count and timestamp delta", () => {
- const peekFn = vi.fn();
- const mockProps = createMockProviderProps({ audio: false });
- const video = mockProps.video as VideoSource;
- video.source.stats.peek = peekFn;
-
- const props: ProviderProps = { video };
- provider = new VideoProvider(props);
-
- // First call - use non-zero timestamp so next call can calculate FPS
- peekFn.mockReturnValue({ frameCount: 100, timestamp: 1000000, bytesReceived: 50000 } as VideoStats);
- provider.setup(context);
- expect(setDisplayData).toHaveBeenCalledWith("1920x1080\nN/A\nN/A");
-
- // Second call: 6 frames in 250ms at 24fps = exactly 24 frames per second
- // frameCount delta = 106 - 100 = 6
- // timestamp delta = 250000 microseconds
- // FPS = 6 / 0.25 = 24.0 fps
- // bytesReceived delta = 100000 - 50000 = 50000 bytes
- // bitrate = 50000 * 8 * 4 = 1600000 bits/s = 1.6Mbps
- peekFn.mockReturnValue({ frameCount: 106, timestamp: 1250000, bytesReceived: 100000 } as VideoStats);
- global.__advanceTime(250);
- intervalCallback?.(250);
-
- expect(setDisplayData).toHaveBeenNthCalledWith(2, "1920x1080\n@24.0 fps\n1.6Mbps");
- });
-
- it("should calculate bitrate from bytesReceived delta", () => {
- const peekFn = vi.fn();
- const mockProps = createMockProviderProps({ audio: false });
- const video = mockProps.video as VideoSource;
- video.source.display.peek = () => ({ width: 1280, height: 720 });
- video.source.stats.peek = peekFn;
-
- const props: ProviderProps = { video };
- provider = new VideoProvider(props);
-
- // First call - use non-zero initial values
- peekFn.mockReturnValue({ frameCount: 0, timestamp: 1000000, bytesReceived: 100000 } as VideoStats);
- provider.setup(context);
-
- // Second call: 5 Mbps = 156250 bytes delta at 250ms
- // (156250 * 8 * 4) / 1_000_000 = 5.0 Mbps
- peekFn.mockReturnValue({ frameCount: 6, timestamp: 1250000, bytesReceived: 256250 } as VideoStats);
- global.__advanceTime(250);
- intervalCallback?.(250);
-
- expect(setDisplayData).toHaveBeenNthCalledWith(2, "1280x720\n@24.0 fps\n5.0Mbps");
- });
-
- it("should display N/A for FPS and bitrate on first call", () => {
- const props: ProviderProps = {};
- provider = new VideoProvider(props);
- provider.setup(context);
-
- expect(setDisplayData).toHaveBeenCalledWith("N/A");
- });
-
- it("should display only resolution when stats are not available", () => {
- const mockProps = createMockProviderProps({ audio: false });
- const video = mockProps.video as VideoSource;
- video.source.display.peek = () => ({ width: 1280, height: 720 });
- video.source.stats.peek = () => undefined;
-
- const props: ProviderProps = { video };
- provider = new VideoProvider(props);
- provider.setup(context);
- expect(setDisplayData).toHaveBeenCalledWith("1280x720\nN/A\nN/A");
- });
-
- it("should format kbps for lower bitrates", () => {
- const peekFn = vi.fn();
- const mockProps = createMockProviderProps({ audio: false });
- const video = mockProps.video as VideoSource;
- video.source.stats.peek = peekFn;
-
- const props: ProviderProps = { video };
- provider = new VideoProvider(props);
-
- // First call - use non-zero initial timestamp
- peekFn.mockReturnValue({ frameCount: 0, timestamp: 1000000, bytesReceived: 100000 } as VideoStats);
- provider.setup(context);
-
- // 256 kbps = 8000 bytes at 250ms
- // (8000 * 8 * 4) / 1000 = 256 kbps
- peekFn.mockReturnValue({ frameCount: 6, timestamp: 1250000, bytesReceived: 108000 } as VideoStats);
- global.__advanceTime(250);
- intervalCallback?.(250);
-
- expect(setDisplayData).toHaveBeenNthCalledWith(2, "1920x1080\n@24.0 fps\n256kbps");
- });
-
- it("should cleanup interval on dispose", () => {
- const props: ProviderProps = {};
- provider = new VideoProvider(props);
- provider.setup(context);
-
- provider.cleanup();
-
- expect(provider).toBeDefined();
- });
-});
diff --git a/js/hang-ui/src/shared/components/stats/__tests__/utils.ts b/js/hang-ui/src/shared/components/stats/__tests__/utils.ts
deleted file mode 100644
index 89b6321f9c..0000000000
--- a/js/hang-ui/src/shared/components/stats/__tests__/utils.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { vi } from "vitest";
-import type { ProviderProps } from "../types";
-
-export interface MockProviderPropsOptions {
- audio?: boolean;
- video?: boolean;
-}
-
-/**
- * Creates mock ProviderProps with configurable audio and video sources.
- * Each signal includes a peek() method and a subscribe() method with vi.fn().
- *
- * @param options - Configuration options
- * @returns ProviderProps with mocked audio and/or video sources
- *
- * @example
- * ```ts
- * // Create mock with both audio and video
- * const props = createMockProviderProps();
- *
- * // Create mock with audio only
- * const audioOnly = createMockProviderProps({ video: false });
- *
- * // Create mock with video only
- * const videoOnly = createMockProviderProps({ audio: false });
- * ```
- */
-export const createMockProviderProps = (options?: MockProviderPropsOptions): ProviderProps => {
- const { audio = true, video = true } = options ?? {};
-
- return {
- ...(audio && {
- audio: {
- source: {
- active: {
- peek: () => "audio-active",
- subscribe: vi.fn(() => vi.fn()),
- changed: vi.fn(() => vi.fn()),
- },
- config: {
- peek: () => ({
- sampleRate: 48000,
- numberOfChannels: 2,
- bitrate: 128000,
- codec: "opus",
- }),
- subscribe: vi.fn(() => vi.fn()),
- changed: vi.fn(() => vi.fn()),
- },
- stats: {
- peek: () => ({
- bytesReceived: 1024,
- }),
- subscribe: vi.fn(() => vi.fn()),
- changed: vi.fn(() => vi.fn()),
- },
- },
- },
- }),
- ...(video && {
- video: {
- source: {
- display: {
- peek: () => ({
- width: 1920,
- height: 1080,
- }),
- subscribe: vi.fn(() => vi.fn()),
- changed: vi.fn(() => vi.fn()),
- },
- syncStatus: {
- peek: () => ({ state: "ready" as const }),
- subscribe: vi.fn(() => vi.fn()),
- changed: vi.fn(() => vi.fn()),
- },
- bufferStatus: {
- peek: () => ({ state: "filled" as const }),
- subscribe: vi.fn(() => vi.fn()),
- changed: vi.fn(() => vi.fn()),
- },
- latency: {
- peek: () => 100,
- subscribe: vi.fn(() => vi.fn()),
- changed: vi.fn(() => vi.fn()),
- },
- stats: {
- peek: () => ({
- frameCount: 60,
- timestamp: Date.now(),
- bytesReceived: 2048,
- }),
- subscribe: vi.fn(() => vi.fn()),
- changed: vi.fn(() => vi.fn()),
- },
- },
- },
- }),
- };
-};
diff --git a/js/hang-ui/src/shared/components/stats/components/StatsItem.tsx b/js/hang-ui/src/shared/components/stats/components/StatsItem.tsx
deleted file mode 100644
index fc9dc5c7b3..0000000000
--- a/js/hang-ui/src/shared/components/stats/components/StatsItem.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { createEffect, createSignal, type JSX, onCleanup } from "solid-js";
-import { getStatsInformationProvider } from "../providers/registry";
-import type { KnownStatsProviders, ProviderProps } from "../types";
-
-/**
- * Props for individual stats metric item
- */
-interface StatsItemProps extends ProviderProps {
- name: string;
- /** Metric type identifier */
- statProvider: KnownStatsProviders;
- /** SVG icon markup */
- svg: JSX.Element;
-}
-
-/**
- * Individual metric display with provider and reactive updates
- */
-export const StatsItem = (props: StatsItemProps) => {
- const [displayData, setDisplayData] = createSignal("N/A");
-
- createEffect(() => {
- const StatsInformationProvider = getStatsInformationProvider(props.statProvider);
-
- if (!StatsInformationProvider) {
- setDisplayData("N/A");
- return;
- }
-
- const provider = new StatsInformationProvider({
- audio: props.audio,
- video: props.video,
- });
-
- provider.setup({ setDisplayData });
-
- onCleanup(() => {
- provider.cleanup();
- });
- });
-
- return (
-
-
{props.svg}
-
- {props.statProvider}
- {displayData()}
-
-
- );
-};
diff --git a/js/hang-ui/src/shared/components/stats/components/StatsPanel.tsx b/js/hang-ui/src/shared/components/stats/components/StatsPanel.tsx
deleted file mode 100644
index 162b5a806b..0000000000
--- a/js/hang-ui/src/shared/components/stats/components/StatsPanel.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { For, type JSX } from "solid-js";
-import * as Icon from "../../icon/icon";
-import type { KnownStatsProviders, ProviderProps } from "../types";
-import { StatsItem } from "./StatsItem";
-
-/**
- * Props for stats panel component
- */
-interface StatsPanelProps extends ProviderProps {}
-
-export const statsDetailItems: { name: string; statProvider: KnownStatsProviders; icon: () => JSX.Element }[] = [
- {
- name: "Network",
- statProvider: "network",
- icon: () => ,
- },
- {
- name: "Video",
- statProvider: "video",
- icon: () => ,
- },
- {
- name: "Audio",
- statProvider: "audio",
- icon: () => ,
- },
- {
- name: "Buffer",
- statProvider: "buffer",
- icon: () => ,
- },
-];
-
-/**
- * Panel displaying all metrics in a grid layout
- */
-export const StatsPanel = (props: StatsPanelProps) => {
- return (
-
-
- {({ name, statProvider, icon }) => (
-
- )}
-
-
- );
-};
diff --git a/js/hang-ui/src/shared/components/stats/index.tsx b/js/hang-ui/src/shared/components/stats/index.tsx
deleted file mode 100644
index d56887bf1a..0000000000
--- a/js/hang-ui/src/shared/components/stats/index.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { type Context, Show, useContext } from "solid-js";
-import { StatsPanel } from "./components/StatsPanel";
-import type { ProviderProps } from "./types";
-
-interface StatsProps {
- context: Context;
- getElement: (ctx: T) => ProviderProps | undefined;
-}
-
-/**
- * Stats component for displaying real-time media streaming metrics
- * Accepts a generic context and a function to extract the media element
- */
-export const Stats = (props: StatsProps) => {
- const contextValue = useContext(props.context);
-
- return (
-
- {(_element) => (
-
-
-
- )}
-
- );
-};
diff --git a/js/hang-ui/src/shared/components/stats/providers/audio.ts b/js/hang-ui/src/shared/components/stats/providers/audio.ts
deleted file mode 100644
index 9a685a5130..0000000000
--- a/js/hang-ui/src/shared/components/stats/providers/audio.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import type { ProviderContext } from "../types";
-import { BaseProvider } from "./base";
-
-/**
- * Provider for audio stream metrics (channels, bitrate, codec)
- */
-export class AudioProvider extends BaseProvider {
- /** Polling interval in milliseconds */
- private static readonly POLLING_INTERVAL_MS = 250;
- /** Display context for updating metrics */
- private context: ProviderContext | undefined;
- /** Polling interval ID */
- private updateInterval: number | undefined;
- /** Previous bytes received for bitrate calculation */
- private previousBytesReceived = 0;
- /** Previous timestamp for accurate elapsed time calculation */
- private previousWhen = 0;
-
- /**
- * Initialize audio provider with polling interval
- */
- setup(context: ProviderContext): void {
- this.context = context;
- const audio = this.props.audio;
-
- if (!audio) {
- context.setDisplayData("N/A");
- return;
- }
-
- this.updateInterval = window.setInterval(this.updateDisplayData.bind(this), AudioProvider.POLLING_INTERVAL_MS);
-
- this.previousWhen = performance.now();
- this.updateDisplayData();
- }
-
- /**
- * Calculate and display current audio metrics
- */
- private updateDisplayData(): void {
- if (!this.context || !this.props.audio) {
- return;
- }
-
- const active = this.props.audio.source.active.peek();
-
- const config = this.props.audio.source.config.peek();
-
- const stats = this.props.audio.source.stats.peek();
-
- if (!active || !config) {
- this.context.setDisplayData("N/A");
- return;
- }
-
- const now = performance.now();
- let bitrate: string | undefined;
- if (stats && this.previousBytesReceived > 0) {
- const bytesDelta = stats.bytesReceived - this.previousBytesReceived;
- // Only calculate bitrate if there's actual data change
- if (bytesDelta > 0) {
- const elapsedMs = now - this.previousWhen;
- if (elapsedMs > 0) {
- const bitsPerSecond = bytesDelta * 8 * (1000 / elapsedMs);
-
- if (bitsPerSecond >= 1_000_000) {
- bitrate = `${(bitsPerSecond / 1_000_000).toFixed(1)}Mbps`;
- } else if (bitsPerSecond >= 1_000) {
- bitrate = `${(bitsPerSecond / 1_000).toFixed(0)}kbps`;
- } else {
- bitrate = `${bitsPerSecond.toFixed(0)}bps`;
- }
- }
- }
- }
-
- // Always update previous values for next calculation, even on first call
- if (stats) {
- this.previousBytesReceived = stats.bytesReceived;
- this.previousWhen = now;
- }
-
- const parts: string[] = [];
-
- if (config.sampleRate) {
- const khz = (config.sampleRate / 1000).toFixed(1);
- parts.push(`${khz}kHz`);
- }
-
- if (config.numberOfChannels) {
- parts.push(`${config.numberOfChannels}ch`);
- }
-
- parts.push(bitrate ?? "N/A");
-
- if (config.codec) {
- parts.push(config.codec);
- }
-
- this.context.setDisplayData(parts.length > 0 ? parts.join("\n") : "N/A");
- }
-
- /**
- * Clean up polling interval
- */
- override cleanup(): void {
- if (this.updateInterval !== undefined) {
- window.clearInterval(this.updateInterval);
- }
- super.cleanup();
- }
-}
diff --git a/js/hang-ui/src/shared/components/stats/providers/base.ts b/js/hang-ui/src/shared/components/stats/providers/base.ts
deleted file mode 100644
index adf1c10e0e..0000000000
--- a/js/hang-ui/src/shared/components/stats/providers/base.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Effect } from "@moq/signals";
-import type { ProviderContext, ProviderProps } from "../types";
-
-/**
- * Base class for metric providers providing common utilities
- */
-export abstract class BaseProvider {
- /** Manages subscriptions lifecycle */
- protected signals = new Effect();
- /** Stream sources provided to provider */
- protected props: ProviderProps;
-
- /**
- * Initialize provider with stream sources
- * @param props - Audio and video stream sources
- */
- constructor(props: ProviderProps) {
- this.props = props;
- }
-
- /**
- * Initialize provider with display context
- * @param context - Provider context for updating display
- */
- abstract setup(context: ProviderContext): void;
-
- /**
- * Clean up subscriptions
- */
- cleanup(): void {
- this.signals.close();
- }
-}
diff --git a/js/hang-ui/src/shared/components/stats/providers/buffer.ts b/js/hang-ui/src/shared/components/stats/providers/buffer.ts
deleted file mode 100644
index b1f29499e4..0000000000
--- a/js/hang-ui/src/shared/components/stats/providers/buffer.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { Getter } from "@moq/signals";
-import type { BufferStatus, ProviderContext, SyncStatus } from "../types";
-import { BaseProvider } from "./base";
-
-/**
- * Provider for buffer metrics (fill percentage, latency)
- */
-export class BufferProvider extends BaseProvider {
- /** Display context for updating metrics */
- private context: ProviderContext | undefined;
-
- /**
- * Initialize buffer provider with signal subscriptions
- */
- setup(context: ProviderContext): void {
- this.context = context;
- const video = this.props.video;
-
- if (!video) {
- context.setDisplayData("N/A");
- return;
- }
-
- this.signals.effect((effect) => {
- const syncStatus = effect.get(video.source.syncStatus as Getter);
- const bufferStatus = effect.get(video.source.bufferStatus as Getter);
- const latency = effect.get(video.source.latency as Getter);
-
- const isLatencyValid = latency !== null && latency !== undefined && latency > 0;
- const bufferPercentage =
- syncStatus?.state === "wait" && syncStatus?.bufferDuration !== undefined && isLatencyValid
- ? Math.min(100, Math.round((syncStatus.bufferDuration / latency) * 100))
- : bufferStatus?.state === "filled"
- ? 100
- : 0;
-
- const parts = [`${bufferPercentage}%`, isLatencyValid ? `${latency}ms` : "N/A"];
-
- this.context?.setDisplayData(parts.join("\n"));
- });
- }
-}
diff --git a/js/hang-ui/src/shared/components/stats/providers/index.ts b/js/hang-ui/src/shared/components/stats/providers/index.ts
deleted file mode 100644
index 94274bd255..0000000000
--- a/js/hang-ui/src/shared/components/stats/providers/index.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- * Public exports for all providers and utilities
- */
-
-export { AudioProvider } from "./audio";
-export { BaseProvider } from "./base";
-export { BufferProvider } from "./buffer";
-export { NetworkProvider } from "./network";
-export { getStatsInformationProvider, providers } from "./registry";
-export { VideoProvider } from "./video";
diff --git a/js/hang-ui/src/shared/components/stats/providers/network.ts b/js/hang-ui/src/shared/components/stats/providers/network.ts
deleted file mode 100644
index c05b27c116..0000000000
--- a/js/hang-ui/src/shared/components/stats/providers/network.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-import type { ProviderContext } from "../types";
-import { BaseProvider } from "./base";
-
-/**
- * Extended Navigator interface with connection property
- */
-interface NavigatorWithConnection extends Navigator {
- /** Standard Network Information API */
- connection?: NetworkInformation;
- /** Mozilla variant */
- mozConnection?: NetworkInformation;
- /** WebKit variant */
- webkitConnection?: NetworkInformation;
-}
-
-/**
- * Network information interface from navigator.connection
- */
-interface NetworkInformation {
- /** Connection type (wifi, cellular, etc) */
- type?: string;
- /** Effective connection speed category */
- effectiveType?: "slow-2g" | "2g" | "3g" | "4g";
- /** Downlink speed in Mbps */
- downlink?: number;
- /** Round-trip time in ms */
- rtt?: number;
- /** User has enabled data saver mode */
- saveData?: boolean;
- /** Listen for connection changes */
- addEventListener?(type: string, listener: () => void): void;
- /** Stop listening for connection changes */
- removeEventListener?(type: string, listener: () => void): void;
-}
-
-/**
- * Provider for network metrics (connection type, bandwidth, latency)
- */
-export class NetworkProvider extends BaseProvider {
- /** Polling interval in milliseconds */
- private static readonly POLLING_INTERVAL_MS = 100;
- /** Display context for updating metrics */
- private context: ProviderContext | undefined;
- /** Network information from navigator.connection */
- private networkInfo?: NetworkInformation;
- /** Polling interval ID */
- private updateInterval?: number;
- private readonly boundUpdateDisplayData = this.updateDisplayData.bind(this);
-
- /**
- * Initialize network provider with connection listeners
- */
- setup(context: ProviderContext): void {
- this.context = context;
-
- const nav = navigator as NavigatorWithConnection;
- this.networkInfo = nav.connection ?? nav.mozConnection ?? nav.webkitConnection;
-
- if (!this.networkInfo) {
- context.setDisplayData("N/A");
- return;
- }
-
- this.networkInfo.addEventListener?.("change", this.boundUpdateDisplayData);
-
- window.addEventListener("online", this.boundUpdateDisplayData);
- window.addEventListener("offline", this.boundUpdateDisplayData);
-
- this.updateInterval = window.setInterval(this.boundUpdateDisplayData, NetworkProvider.POLLING_INTERVAL_MS);
- this.updateDisplayData();
- }
-
- /**
- * Clean up event listeners and polling interval
- */
- override cleanup(): void {
- if (this.networkInfo?.removeEventListener) {
- this.networkInfo.removeEventListener("change", this.boundUpdateDisplayData);
- }
- window.removeEventListener("online", this.boundUpdateDisplayData);
- window.removeEventListener("offline", this.boundUpdateDisplayData);
- if (this.updateInterval !== undefined) {
- clearInterval(this.updateInterval);
- }
- super.cleanup();
- }
-
- /**
- * Calculate and display current network metrics
- */
- private updateDisplayData(): void {
- if (!this.context) {
- return;
- }
-
- const parts = [
- this.getConnectionType(),
- this.getEffectiveBandwidth(),
- this.getLatency(),
- this.getSaveDataStatus(),
- ].filter((part): part is string => part !== null);
-
- this.context.setDisplayData(parts.length > 0 ? parts.join("\n") : "N/A");
- }
-
- /**
- * Get formatted connection type
- * @returns Connection type or null if unavailable
- */
- private getConnectionType(): string | null {
- if (!navigator.onLine) {
- return "offline";
- }
-
- if (!this.networkInfo) {
- return null;
- }
-
- const effectiveType = this.networkInfo.effectiveType;
- if (effectiveType) {
- const typeMap = {
- "slow-2g": "Slow-2G",
- "2g": "2G",
- "3g": "3G",
- "4g": "4G",
- };
- return typeMap[effectiveType];
- }
-
- const type = this.networkInfo.type;
- return type ? type.charAt(0).toUpperCase() + type.slice(1) : null;
- }
-
- /**
- * Get formatted bandwidth in Mbps or Gbps
- * @returns Bandwidth string or null if unavailable
- */
- private getEffectiveBandwidth(): string | null {
- const downlink = this.networkInfo?.downlink;
- if (!downlink || downlink <= 0) return null;
-
- if (downlink >= 1000) {
- return `${(downlink / 1000).toFixed(1)}Gbps`;
- }
- if (downlink >= 1) {
- return `${downlink.toFixed(1)}Mbps`;
- }
- return `${(downlink * 1000).toFixed(0)}Kbps`;
- }
-
- /**
- * Get formatted round-trip latency
- * @returns Latency string or null if unavailable
- */
- private getLatency(): string | null {
- const rtt = this.networkInfo?.rtt;
- return rtt && rtt > 0 ? `${rtt}ms` : null;
- }
-
- /**
- * Get data saver mode status
- * @returns Data saver indicator or null if disabled
- */
- private getSaveDataStatus(): string | null {
- return this.networkInfo?.saveData ? "Save-Data" : null;
- }
-}
diff --git a/js/hang-ui/src/shared/components/stats/providers/registry.ts b/js/hang-ui/src/shared/components/stats/providers/registry.ts
deleted file mode 100644
index e627569f86..0000000000
--- a/js/hang-ui/src/shared/components/stats/providers/registry.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import type { KnownStatsProviders, ProviderProps } from "../types";
-import { AudioProvider } from "./audio";
-import type { BaseProvider } from "./base";
-import { BufferProvider } from "./buffer";
-import { NetworkProvider } from "./network";
-import { VideoProvider } from "./video";
-
-/**
- * Constructor type for metric provider classes
- */
-export type ProviderConstructor = new (props: ProviderProps) => BaseProvider;
-
-/**
- * Registry mapping metric types to their provider implementations
- */
-export const providers: Record = {
- video: VideoProvider,
- audio: AudioProvider,
- buffer: BufferProvider,
- network: NetworkProvider,
-};
-
-/**
- * Get provider class for a metric type
- * @param statProvider - Metric type identifier
- * @returns Provider constructor or undefined if not found
- */
-export function getStatsInformationProvider(statProvider: KnownStatsProviders): ProviderConstructor | undefined {
- return providers[statProvider];
-}
diff --git a/js/hang-ui/src/shared/components/stats/providers/video.ts b/js/hang-ui/src/shared/components/stats/providers/video.ts
deleted file mode 100644
index 8532100609..0000000000
--- a/js/hang-ui/src/shared/components/stats/providers/video.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import type { ProviderContext } from "../types";
-import { BaseProvider } from "./base";
-
-/**
- * Provider for video stream metrics (resolution, frame rate, bitrate)
- */
-export class VideoProvider extends BaseProvider {
- /** Polling interval in milliseconds */
- private static readonly POLLING_INTERVAL_MS = 250;
- /** Display context for updating metrics */
- private context: ProviderContext | undefined;
- /** Polling interval ID */
- private updateInterval: number | undefined;
- /** Bound callback for display updates */
- /** Previous frame count for FPS calculation */
- private previousFrameCount = 0;
- /** Previous timestamp for FPS calculation */
- private previousTimestamp = 0;
- /** Previous bytes received for bitrate calculation */
- private previousBytesReceived = 0;
- /** Previous timestamp for accurate elapsed time calculation in bitrate */
- private previousWhen = 0;
-
- /**
- * Initialize video provider with polling interval
- */
- setup(context: ProviderContext): void {
- this.context = context;
- const video = this.props.video;
-
- if (!video) {
- context.setDisplayData("N/A");
- return;
- }
-
- this.updateInterval = window.setInterval(this.updateDisplayData.bind(this), VideoProvider.POLLING_INTERVAL_MS);
- this.previousWhen = performance.now();
- this.updateDisplayData();
- }
-
- /**
- * Calculate and display current video metrics
- */
- private updateDisplayData(): void {
- if (!this.context || !this.props.video) {
- return;
- }
-
- const display = this.props.video.source.display.peek();
- const stats = this.props.video.source.stats.peek();
- const now = performance.now();
-
- // Calculate FPS from frame count delta and timestamp delta
- let fps: number | undefined;
- if (stats && this.previousTimestamp > 0) {
- const frameCountDelta = stats.frameCount - this.previousFrameCount;
- const timestampDeltaUs = stats.timestamp - this.previousTimestamp;
-
- if (timestampDeltaUs > 0 && frameCountDelta > 0) {
- const elapsedSeconds = timestampDeltaUs / 1_000_000;
- fps = frameCountDelta / elapsedSeconds;
- }
- }
-
- let bitrate: string | undefined;
- if (stats && this.previousBytesReceived > 0) {
- const bytesDelta = stats.bytesReceived - this.previousBytesReceived;
- // Only calculate bitrate if there's actual data change
- if (bytesDelta > 0) {
- const elapsedMs = now - this.previousWhen;
- if (elapsedMs > 0) {
- const bitsPerSecond = bytesDelta * 8 * (1000 / elapsedMs);
-
- if (bitsPerSecond >= 1_000_000) {
- bitrate = `${(bitsPerSecond / 1_000_000).toFixed(1)}Mbps`;
- } else if (bitsPerSecond >= 1_000) {
- bitrate = `${(bitsPerSecond / 1_000).toFixed(0)}kbps`;
- } else {
- bitrate = `${bitsPerSecond.toFixed(0)}bps`;
- }
- }
- }
- }
-
- // Always update previous values for next calculation, even on first call
- if (stats) {
- this.previousFrameCount = stats.frameCount;
- this.previousTimestamp = stats.timestamp;
- this.previousBytesReceived = stats.bytesReceived;
- this.previousWhen = now;
- }
-
- const { width, height } = display ?? {};
-
- const parts = [
- width && height ? `${width}x${height}` : "N/A",
- fps !== undefined ? `@${fps.toFixed(1)} fps` : "N/A",
- bitrate ?? "N/A",
- ];
-
- this.context.setDisplayData(parts.join("\n"));
- }
-
- /**
- * Clean up polling interval
- */
- override cleanup(): void {
- if (this.updateInterval !== undefined) {
- clearInterval(this.updateInterval);
- }
- super.cleanup();
- }
-}
diff --git a/js/hang-ui/src/shared/components/stats/styles/index.css b/js/hang-ui/src/shared/components/stats/styles/index.css
deleted file mode 100644
index dc6468e2e5..0000000000
--- a/js/hang-ui/src/shared/components/stats/styles/index.css
+++ /dev/null
@@ -1,161 +0,0 @@
-.stats {
- display: flex;
- align-items: center;
- justify-content: center;
- position: absolute;
- inset: 0;
- pointer-events: none;
- z-index: 2;
- width: 100%;
- height: inherit;
- container-type: inline-size;
-}
-
-.stats .stats__panel {
- position: absolute;
- display: flex;
- align-items: flex-start;
- justify-content: center;
- flex-direction: row;
- flex-wrap: wrap;
- gap: var(--spacing-12);
- max-width: 240px;
- width: 100%;
- background-color: var(--color-black-alpha-85);
- border: 1px solid var(--color-white-alpha-25);
- border-radius: 8px;
- padding: var(--spacing-12);
- pointer-events: auto;
- top: 8px;
-}
-
-@container (width > 480px) and (width <= 768px) {
- .stats .stats__panel {
- max-width: 360px;
- padding: var(--spacing-24);
- }
-}
-
-@container (width > 768px) {
- .stats .stats__panel {
- max-width: 600px;
- padding: var(--spacing-24);
- }
-}
-
-.stats .stats__panel .stats__item {
- display: flex;
- align-items: flex-start;
- justify-content: flex-start;
- flex-direction: row;
- height: auto;
- column-gap: var(--spacing-8);
- flex-basis: calc(50% - var(--spacing-12));
- flex-grow: 1;
- flex-shrink: 1;
-}
-
-@container (width > 480px) {
- .stats .stats__panel .stats__item {
- flex-basis: calc(25% - var(--spacing-12));
- }
-}
-
-.stats .stats__panel .stats__item .stats__icon-wrapper {
- display: none;
- padding: var(--spacing-8);
- border-radius: 8px;
- width: fit-content;
- height: fit-content;
- flex-shrink: 0;
-}
-
-@container (width > 480px) {
- .stats .stats__panel .stats__item .stats__icon-wrapper {
- width: 24px;
- height: 24px;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-direction: row;
- }
-}
-
-.stats .stats__panel .stats__item .stats__icon-wrapper svg {
- width: 100%;
- height: 100%;
-}
-
-.stats .stats__panel .stats__item.stats__item--network .stats__icon-wrapper {
- background-color: var(--color-blue-900);
-}
-
-.stats .stats__panel .stats__item.stats__item--network {
- color: var(--color-blue-400);
-}
-
-.stats .stats__panel .stats__item.stats__item--network .stats__item-data {
- color: var(--color-blue-400);
-}
-
-.stats .stats__panel .stats__item.stats__item--video .stats__icon-wrapper {
- background-color: var(--color-purple-900);
-}
-
-.stats .stats__panel .stats__item.stats__item--video {
- color: var(--color-purple-400);
-}
-
-.stats .stats__panel .stats__item.stats__item--video .stats__item-data {
- color: var(--color-purple-400);
-}
-
-.stats .stats__panel .stats__item.stats__item--audio .stats__icon-wrapper {
- background-color: var(--color-green-900);
-}
-
-.stats .stats__panel .stats__item.stats__item--audio {
- color: var(--color-green-400);
-}
-
-.stats .stats__panel .stats__item.stats__item--audio .stats__item-data {
- color: var(--color-green-400);
-}
-
-.stats .stats__panel .stats__item.stats__item--buffer .stats__icon-wrapper {
- background-color: var(--color-orange-900);
-}
-
-.stats .stats__panel .stats__item.stats__item--buffer {
- color: var(--color-orange-400);
-}
-
-.stats .stats__panel .stats__item.stats__item--buffer .stats__item-data {
- color: var(--color-orange-400);
-}
-
-.stats .stats__panel .stats__item .stats__item-detail {
- display: flex;
- align-items: flex-start;
- justify-content: space-evenly;
- flex-direction: column;
- gap: var(--spacing-4);
-}
-
-.stats .stats__panel .stats__item .stats__item-detail .stats__item-title {
- font-size: 12px;
- font-weight: 500;
- text-transform: capitalize;
- color: var(--color-gray-100);
- font-family: "Segoe UI", Roboto, sans-serif;
- letter-spacing: 1px;
- line-height: 12px;
-}
-
-.stats .stats__panel .stats__item .stats__item-detail .stats__item-data {
- font-size: 16px;
- font-weight: bold;
- font-family: "Segoe UI", Roboto, sans-serif;
- white-space: pre-wrap;
- line-height: 1.5;
-}
diff --git a/js/hang-ui/src/shared/components/stats/types.ts b/js/hang-ui/src/shared/components/stats/types.ts
deleted file mode 100644
index bb73c4730f..0000000000
--- a/js/hang-ui/src/shared/components/stats/types.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-export type KnownStatsProviders = "network" | "video" | "audio" | "buffer";
-
-/**
- * Context passed to providers for updating display data
- */
-export interface ProviderContext {
- setDisplayData: (data: string) => void;
-}
-
-/**
- * Video resolution dimensions
- */
-export interface VideoResolution {
- width: number;
- height: number;
-}
-
-/**
- * Stream sync status with buffer information
- */
-export interface SyncStatus {
- state: "ready" | "wait";
- bufferDuration?: number;
-}
-
-/**
- * Stream buffer fill status
- */
-export interface BufferStatus {
- state: "empty" | "filled";
-}
-
-/**
- * Generic reactive signal interface for accessing stream data
- */
-export interface Signal {
- peek(): T | undefined;
- changed?(callback: (value: T | undefined) => void): () => void;
- subscribe?(callback: () => void): () => void;
-}
-
-/**
- * Audio stream statistics
- */
-export type AudioStats = {
- bytesReceived: number;
-};
-
-/**
- * Audio stream source with reactive properties
- */
-export interface AudioSource {
- source: {
- active: Signal;
- config: Signal;
- stats: Signal;
- };
-}
-
-/**
- * Audio stream configuration properties
- */
-export interface AudioConfig {
- sampleRate: number;
- numberOfChannels: number;
- bitrate?: number;
- codec: string;
-}
-
-/**
- * Video stream statistics
- */
-export type VideoStats = {
- frameCount: number;
- timestamp: number;
- bytesReceived: number;
-};
-
-/**
- * Video stream source with reactive properties
- */
-export interface VideoSource {
- source: {
- display: Signal;
- syncStatus: Signal;
- bufferStatus: Signal;
- latency: Signal;
- stats: Signal;
- };
-}
-
-/**
- * Props passed to metric providers containing stream sources
- */
-export interface ProviderProps {
- audio?: AudioSource;
- video?: VideoSource;
-}
diff --git a/js/hang-ui/src/shared/flex.css b/js/hang-ui/src/shared/flex.css
deleted file mode 100644
index d67ea3c9fc..0000000000
--- a/js/hang-ui/src/shared/flex.css
+++ /dev/null
@@ -1,9 +0,0 @@
-:is(.flex--center) {
- display: flex;
- justify-content: center;
- align-items: center;
-}
-
-:is(.flex--align-center) {
- align-items: center;
-}
diff --git a/js/hang-ui/src/shared/variables.css b/js/hang-ui/src/shared/variables.css
deleted file mode 100644
index 1b4fe0c9c9..0000000000
--- a/js/hang-ui/src/shared/variables.css
+++ /dev/null
@@ -1,32 +0,0 @@
-:host {
- --color-white: #ffffff;
- --color-white-alpha-10: rgba(255, 255, 255, 0.1);
- --color-white-alpha-20: rgba(255, 255, 255, 0.2);
- --color-white-alpha-25: rgba(255, 255, 255, 0.25);
- --color-white-alpha-30: rgba(255, 255, 255, 0.3);
- --color-gray-100: #9ca3af;
- --color-blue-400: #00dfff;
- --color-blue-900: #1b3243;
- --color-purple-400: #a855f7;
- --color-purple-900: #31144a;
- --color-green-400: #65ee2e;
- --color-green-900: #3d4928;
- --color-orange-400: #ff9900;
- --color-orange-500: #f97316;
- --color-orange-900: #4d301b;
- --color-amber-600: #d97706;
- --color-orange-500-alpha-60: rgba(249, 115, 22, 0.6);
- --color-orange-500-alpha-80: rgba(249, 115, 22, 0.8);
- --color-red: #e60000;
- --color-black-alpha-8: rgba(0, 0, 0, 0.08);
- --color-black-alpha-10: rgba(0, 0, 0, 0.1);
- --color-black-alpha-85: rgba(0, 0, 0, 0.85);
- --color-black: #000000;
- --spacing-1: 0.0625rem; /* 1px */
- --spacing-2: 0.125rem; /* 2px */
- --spacing-4: 0.25rem; /* 4px */
- --spacing-8: 0.5rem; /* 8px */
- --spacing-12: 0.75rem; /* 12px */
- --spacing-16: 1rem; /* 16px */
- --spacing-24: 1.5rem; /* 24px */
-}
diff --git a/js/hang-ui/src/watch/components/BufferingIndicator.tsx b/js/hang-ui/src/watch/components/BufferingIndicator.tsx
deleted file mode 100644
index 96bb393b92..0000000000
--- a/js/hang-ui/src/watch/components/BufferingIndicator.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Show } from "solid-js";
-import useWatchUIContext from "../hooks/use-watch-ui";
-
-export default function BufferingIndicator() {
- const context = useWatchUIContext();
-
- return (
-
-
-
- );
-}
diff --git a/js/hang-ui/src/watch/components/FullscreenButton.tsx b/js/hang-ui/src/watch/components/FullscreenButton.tsx
deleted file mode 100644
index c1cab0d636..0000000000
--- a/js/hang-ui/src/watch/components/FullscreenButton.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Show } from "solid-js";
-import Button from "../../shared/components/button/button";
-import * as Icon from "../../shared/components/icon/icon";
-import useWatchUIContext from "../hooks/use-watch-ui";
-
-export default function FullscreenButton() {
- const context = useWatchUIContext();
-
- const onClick = () => {
- context.toggleFullscreen();
- };
-
- return (
-
- }>
-
-
-
- );
-}
diff --git a/js/hang-ui/src/watch/components/LatencySlider.tsx b/js/hang-ui/src/watch/components/LatencySlider.tsx
deleted file mode 100644
index 85db1b8def..0000000000
--- a/js/hang-ui/src/watch/components/LatencySlider.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import useWatchUIContext from "../hooks/use-watch-ui";
-
-const MIN_RANGE = 0;
-const MAX_RANGE = 5_000;
-const RANGE_STEP = 100;
-
-export default function LatencySlider() {
- const context = useWatchUIContext();
- const onInputChange = (event: Event) => {
- const target = event.currentTarget as HTMLInputElement;
- const latency = parseFloat(target.value);
- context.setLatencyValue(latency);
- };
-
- return (
-
-
- Latency:{" "}
-
-
- {typeof context.latency() !== "undefined" ? `${Math.round(context.latency())}ms` : ""}
-
- );
-}
diff --git a/js/hang-ui/src/watch/components/PlayPauseButton.tsx b/js/hang-ui/src/watch/components/PlayPauseButton.tsx
deleted file mode 100644
index 6790eaef1e..0000000000
--- a/js/hang-ui/src/watch/components/PlayPauseButton.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Show } from "solid-js";
-import Button from "../../shared/components/button/button";
-import * as Icon from "../../shared/components/icon/icon";
-import useWatchUIContext from "../hooks/use-watch-ui";
-
-export default function PlayPauseButton() {
- const context = useWatchUIContext();
- const onClick = () => {
- context.togglePlayback();
- };
-
- return (
-
- }>
-
-
-
- );
-}
diff --git a/js/hang-ui/src/watch/components/QualitySelector.tsx b/js/hang-ui/src/watch/components/QualitySelector.tsx
deleted file mode 100644
index 92bf0da2f7..0000000000
--- a/js/hang-ui/src/watch/components/QualitySelector.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { For, type JSX } from "solid-js";
-import useWatchUIContext from "../hooks/use-watch-ui";
-
-export default function QualitySelector() {
- const context = useWatchUIContext();
-
- const handleQualityChange: JSX.EventHandler = (event) => {
- const selectedValue = event.currentTarget.value || undefined;
- context.setActiveRendition(selectedValue);
- };
-
- return (
-
-
- Quality:{" "}
-
-
- Auto
-
- {(rendition) => (
-
- {rendition.name}
- {rendition.width && rendition.height ? ` (${rendition.width}x${rendition.height})` : ""}
-
- )}
-
-
-
- );
-}
diff --git a/js/hang-ui/src/watch/components/StatsButton.tsx b/js/hang-ui/src/watch/components/StatsButton.tsx
deleted file mode 100644
index ba6a94015c..0000000000
--- a/js/hang-ui/src/watch/components/StatsButton.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import Button from "../../shared/components/button/button";
-import * as Icon from "../../shared/components/icon/icon";
-import useWatchUIContext from "../hooks/use-watch-ui";
-
-/**
- * Toggle button for showing/hiding stats panel
- */
-export default function StatsButton() {
- const context = useWatchUIContext();
-
- const onClick = () => {
- context.setIsStatsPanelVisible(!context.isStatsPanelVisible());
- };
-
- return (
-
-
-
- );
-}
diff --git a/js/hang-ui/src/watch/components/VolumeSlider.tsx b/js/hang-ui/src/watch/components/VolumeSlider.tsx
deleted file mode 100644
index 957acac4b8..0000000000
--- a/js/hang-ui/src/watch/components/VolumeSlider.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { createEffect, createSignal } from "solid-js";
-import Button from "../../shared/components/button/button";
-import * as Icon from "../../shared/components/icon/icon";
-import useWatchUIContext from "../hooks/use-watch-ui";
-
-const getVolumeIcon = (volume: number, isMuted: boolean) => {
- if (isMuted || volume === 0) {
- return ;
- } else if (volume > 0 && volume <= 33) {
- return ;
- } else if (volume > 33 && volume <= 66) {
- return ;
- } else {
- return ;
- }
-};
-
-export default function VolumeSlider() {
- const [volumeLabel, setVolumeLabel] = createSignal(0);
- const context = useWatchUIContext();
-
- const onInputChange = (event: Event) => {
- const el = event.currentTarget as HTMLInputElement;
- const volume = parseFloat(el.value);
- context.setVolume(volume);
- };
-
- createEffect(() => {
- const currentVolume = context.currentVolume() || 0;
- setVolumeLabel(Math.round(currentVolume));
- });
-
- return (
-
- context.toggleMuted()}>
- {getVolumeIcon(context.currentVolume(), context.isMuted())}
-
-
- {volumeLabel()}
-
- );
-}
diff --git a/js/hang-ui/src/watch/components/WatchControls.tsx b/js/hang-ui/src/watch/components/WatchControls.tsx
deleted file mode 100644
index b718454d80..0000000000
--- a/js/hang-ui/src/watch/components/WatchControls.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import FullscreenButton from "./FullscreenButton";
-import LatencySlider from "./LatencySlider";
-import PlayPauseButton from "./PlayPauseButton";
-import QualitySelector from "./QualitySelector";
-import StatsButton from "./StatsButton";
-import VolumeSlider from "./VolumeSlider";
-import WatchStatusIndicator from "./WatchStatusIndicator";
-
-export default function WatchControls() {
- return (
-
- );
-}
diff --git a/js/hang-ui/src/watch/components/WatchStatusIndicator.tsx b/js/hang-ui/src/watch/components/WatchStatusIndicator.tsx
deleted file mode 100644
index 5d62b7cb23..0000000000
--- a/js/hang-ui/src/watch/components/WatchStatusIndicator.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Match, Switch } from "solid-js";
-import useWatchUIContext from "../hooks/use-watch-ui";
-
-export default function WatchStatusIndicator() {
- const context = useWatchUIContext();
-
- return (
-
-
- 🔴 No URL
- 🔴 Disconnected
- 🟡 Connecting...
- 🔴 Offline
- 🟡 Loading...
- 🟢 Live
- 🟢 Connected
-
-
- );
-}
diff --git a/js/hang-ui/src/watch/context.tsx b/js/hang-ui/src/watch/context.tsx
deleted file mode 100644
index 8eaa9e8de6..0000000000
--- a/js/hang-ui/src/watch/context.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-import { type Moq, Signals } from "@moq/hang";
-import type * as Catalog from "@moq/hang/catalog";
-import type HangWatch from "@moq/hang/watch/element";
-import type { JSX } from "solid-js";
-import { createContext, createSignal, onCleanup } from "solid-js";
-
-type WatchUIContextProviderProps = {
- hangWatch: HangWatch;
- children: JSX.Element;
-};
-
-type WatchStatus = "no-url" | "disconnected" | "connecting" | "offline" | "loading" | "live" | "connected";
-
-export type Rendition = {
- name: string;
- width?: number;
- height?: number;
-};
-
-type WatchUIContextValues = {
- hangWatch: HangWatch;
- watchStatus: () => WatchStatus;
- isPlaying: () => boolean;
- isMuted: () => boolean;
- setVolume: (vol: number) => void;
- currentVolume: () => number;
- togglePlayback: () => void;
- toggleMuted: () => void;
- buffering: () => boolean;
- latency: () => number;
- setLatencyValue: (value: number) => void;
- availableRenditions: () => Rendition[];
- activeRendition: () => string | undefined;
- setActiveRendition: (name: string | undefined) => void;
- isStatsPanelVisible: () => boolean;
- setIsStatsPanelVisible: (visible: boolean) => void;
- isFullscreen: () => boolean;
- toggleFullscreen: () => void;
-};
-
-export const WatchUIContext = createContext();
-
-export default function WatchUIContextProvider(props: WatchUIContextProviderProps) {
- const [watchStatus, setWatchStatus] = createSignal("no-url");
- const [isPlaying, setIsPlaying] = createSignal(false);
- const [isMuted, setIsMuted] = createSignal(false);
- const [currentVolume, setCurrentVolume] = createSignal(0);
- const [buffering, setBuffering] = createSignal(false);
- const [latency, setLatency] = createSignal(0);
- const [availableRenditions, setAvailableRenditions] = createSignal([]);
- const [activeRendition, setActiveRendition] = createSignal(undefined);
- const [isStatsPanelVisible, setIsStatsPanelVisible] = createSignal(false);
- const [isFullscreen, setIsFullscreen] = createSignal(!!document.fullscreenElement);
-
- const togglePlayback = () => {
- props.hangWatch.paused.set(!props.hangWatch.paused.get());
- };
-
- const toggleFullscreen = () => {
- if (document.fullscreenElement) {
- document.exitFullscreen();
- } else {
- props.hangWatch.requestFullscreen();
- }
- };
-
- const setVolume = (volume: number) => {
- props.hangWatch.volume.set(volume / 100);
- };
-
- const toggleMuted = () => {
- props.hangWatch.muted.update((muted) => !muted);
- };
-
- const setLatencyValue = (latency: number) => {
- props.hangWatch.latency.set(latency as Moq.Time.Milli);
- };
-
- const setActiveRenditionValue = (name: string | undefined) => {
- props.hangWatch.video.source.target.update((prev) => ({
- ...prev,
- rendition: name,
- }));
- };
-
- const value: WatchUIContextValues = {
- hangWatch: props.hangWatch,
- watchStatus,
- togglePlayback,
- isPlaying,
- setVolume,
- isMuted,
- currentVolume,
- toggleMuted,
- buffering,
- latency,
- setLatencyValue,
- availableRenditions,
- activeRendition,
- setActiveRendition: setActiveRenditionValue,
- isStatsPanelVisible,
- setIsStatsPanelVisible,
- isFullscreen,
- toggleFullscreen,
- };
-
- const watch = props.hangWatch;
- const signals = new Signals.Effect();
-
- signals.effect((effect) => {
- const url = effect.get(watch.connection.url);
- const connection = effect.get(watch.connection.status);
- const broadcast = effect.get(watch.broadcast.status);
-
- if (!url) {
- setWatchStatus("no-url");
- } else if (connection === "disconnected") {
- setWatchStatus("disconnected");
- } else if (connection === "connecting") {
- setWatchStatus("connecting");
- } else if (broadcast === "offline") {
- setWatchStatus("offline");
- } else if (broadcast === "loading") {
- setWatchStatus("loading");
- } else if (broadcast === "live") {
- setWatchStatus("live");
- } else if (connection === "connected") {
- setWatchStatus("connected");
- }
- });
-
- signals.effect((effect) => {
- const paused = effect.get(watch.video.paused);
- setIsPlaying(!paused);
- });
-
- signals.effect((effect) => {
- const volume = effect.get(watch.audio.volume);
- setCurrentVolume(volume * 100);
- });
-
- signals.effect((effect) => {
- const muted = effect.get(watch.audio.muted);
- setIsMuted(muted);
- });
-
- signals.effect((effect) => {
- const syncStatus = effect.get(watch.video.source.syncStatus);
- const bufferStatus = effect.get(watch.video.source.bufferStatus);
- const shouldShow = syncStatus.state === "wait" || bufferStatus.state === "empty";
-
- setBuffering(shouldShow);
- });
-
- signals.effect((effect) => {
- const latency = effect.get(watch.latency);
- setLatency(latency);
- });
-
- signals.effect((effect) => {
- const rootCatalog = effect.get(watch.broadcast.catalog);
- const videoCatalog = rootCatalog?.video;
- const renditions = videoCatalog?.renditions ?? {};
-
- const renditionsList: Rendition[] = Object.entries(renditions).map(([name, config]) => ({
- name,
- width: (config as Catalog.VideoConfig).codedWidth,
- height: (config as Catalog.VideoConfig).codedHeight,
- }));
-
- setAvailableRenditions(renditionsList);
- });
-
- signals.effect((effect) => {
- const selected = effect.get(watch.video.source.active);
- setActiveRendition(selected);
- });
-
- const handleFullscreenChange = () => {
- setIsFullscreen(!!document.fullscreenElement);
- };
-
- signals.event(document, "fullscreenchange", handleFullscreenChange);
- onCleanup(() => signals.close());
-
- return {props.children} ;
-}
diff --git a/js/hang-ui/src/watch/element.tsx b/js/hang-ui/src/watch/element.tsx
deleted file mode 100644
index 978b307f78..0000000000
--- a/js/hang-ui/src/watch/element.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import type HangWatch from "@moq/hang/watch/element";
-import { useContext } from "solid-js";
-import { Show } from "solid-js/web";
-import { Stats } from "../shared/components/stats";
-import type { ProviderProps } from "../shared/components/stats/types";
-import BufferingIndicator from "./components/BufferingIndicator";
-import WatchControls from "./components/WatchControls";
-import WatchUIContextProvider, { WatchUIContext } from "./context";
-import styles from "./styles/index.css?inline";
-
-export function WatchUI(props: { watch: HangWatch }) {
- return (
-
-
-
-
- {(() => {
- const context = useContext(WatchUIContext);
- if (!context) return null;
- return (
-
- {
- if (!ctx?.hangWatch) return undefined;
- return {
- audio: { source: ctx.hangWatch.audio.source },
- video: { source: ctx.hangWatch.video.source },
- };
- }}
- />
-
- );
- })()}
-
-
-
-
- );
-}
diff --git a/js/hang-ui/src/watch/hooks/use-watch-ui.ts b/js/hang-ui/src/watch/hooks/use-watch-ui.ts
deleted file mode 100644
index 9a52540623..0000000000
--- a/js/hang-ui/src/watch/hooks/use-watch-ui.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useContext } from "solid-js";
-import { WatchUIContext } from "../context";
-
-export default function useWatchUIContext() {
- const context = useContext(WatchUIContext);
-
- if (!context) {
- throw new Error("useWatchUIContext must be used within a WatchUIContextProvider");
- }
-
- return context;
-}
diff --git a/js/hang-ui/src/watch/index.tsx b/js/hang-ui/src/watch/index.tsx
deleted file mode 100644
index cb85313dc5..0000000000
--- a/js/hang-ui/src/watch/index.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import type HangWatch from "@moq/hang/watch/element";
-import { customElement } from "solid-element";
-import { createSignal, onMount, Show } from "solid-js";
-import { WatchUI } from "./element.tsx";
-
-customElement("hang-watch-ui", (_, { element }) => {
- const [nested, setNested] = createSignal();
-
- onMount(async () => {
- await customElements.whenDefined("hang-watch");
- const watchEl = element.querySelector("hang-watch");
- setNested(watchEl ? (watchEl as HangWatch) : undefined);
- });
-
- return (
-
- {(watch: HangWatch) => }
-
- );
-});
diff --git a/js/hang-ui/src/watch/styles/index.css b/js/hang-ui/src/watch/styles/index.css
deleted file mode 100644
index cab710734e..0000000000
--- a/js/hang-ui/src/watch/styles/index.css
+++ /dev/null
@@ -1,204 +0,0 @@
-@import "../../shared/variables.css";
-@import "../../shared/flex.css";
-@import "../../shared/components/button/button.css";
-@import "../../shared/components/stats/styles/index.css";
-
-.watchVideoContainer {
- display: block;
- position: relative;
- width: 100%;
- height: auto;
- border-radius: 4px;
- margin: 0 auto;
- pointer-events: none;
-}
-
-.watchControlsContainer {
- display: flex;
- flex-direction: column;
-}
-
-.button.button--playback {
- width: 3rem;
- height: 3rem;
- background: linear-gradient(
- to bottom right,
- var(--color-orange-400),
- var(--color-orange-500),
- var(--color-amber-600)
- );
- box-shadow: 0 0 var(--spacing-16) var(--color-orange-500-alpha-60);
- transition: box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1);
- position: relative;
- overflow: hidden;
-}
-
-.button.button--playback:hover:not(:disabled) {
- box-shadow: 0 0 var(--spacing-24) var(--color-orange-500-alpha-80);
- transform: scale(1.02);
-}
-
-.button.button--playback:active:not(:disabled) {
- transform: scale(0.98);
-}
-
-.button.button--playback::before {
- content: "";
- position: absolute;
- inset: 0;
- background: linear-gradient(to bottom right, var(--color-white-alpha-30), transparent);
- opacity: 0;
- transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
- pointer-events: none;
-}
-
-.button.button--playback:hover::before {
- opacity: 1;
-}
-
-.button.button--playback::after {
- content: "";
- position: absolute;
- inset: 0;
- background-color: var(--color-white-alpha-20);
- border-radius: 50%;
- filter: blur(var(--spacing-24));
- pointer-events: none;
-}
-
-.volumeSliderContainer {
- display: flex;
- align-items: center;
- gap: 0.25rem;
-}
-
-.volumeLabel {
- display: inline-block;
- width: 2em;
- text-align: right;
-}
-
-@keyframes buffer-spin {
- 0% {
- transform: rotate(0deg);
- }
- 100% {
- transform: rotate(360deg);
- }
-}
-
-.bufferingContainer {
- position: absolute;
- display: flex;
- justify-content: center;
- align-items: center;
- width: 100%;
- height: 100%;
- top: 0;
- left: 0;
- z-index: 1;
- background-color: rgba(0, 0, 0, 0.4);
- backdrop-filter: blur(2px);
- pointer-events: auto;
-}
-
-.bufferingSpinner {
- width: 40px;
- height: 40px;
- border: 4px solid rgba(255, 255, 255, 0.2);
- border-top: 4px solid #fff;
- border-radius: 50%;
- animation: buffer-spin 1s linear infinite;
-}
-
-.latencySliderContainer {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- background: transparent;
- border-radius: 8px;
- margin: 8px 0;
-}
-
-.latencyLabel {
- font-size: 20px;
- font-weight: 500;
- color: #fff;
- white-space: nowrap;
-}
-
-.latencySlider {
- flex: 1 1 0%;
- height: 6px;
- border-radius: 3px;
- background: transparent;
- cursor: pointer;
-}
-
-.latencyValueDisplay {
- font-size: 20px;
- min-width: 60px;
- text-align: right;
- color: #fff;
-}
-
-.playbackControlsRow {
- display: flex;
- place-content: center space-around;
- gap: 8px;
- padding: 0.5rem 0;
-}
-
-.lantencyControlsRow {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- background: transparent;
- border-radius: 8px;
- margin: 8px 0px;
-}
-
-.qualitySelectorContainer {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- background: transparent;
- border-radius: 8px;
- margin: 8px 0;
-}
-
-.qualityLabel {
- font-size: 20px;
- font-weight: 500;
- color: #fff;
- white-space: nowrap;
-}
-
-.qualitySelect {
- flex: 0 1 auto;
- font-size: 16px;
- padding: 4px 8px;
- border-radius: 4px;
- background: rgba(255, 255, 255, 0.1);
- color: #fff;
- border: 1px solid rgba(255, 255, 255, 0.2);
- cursor: pointer;
- transition: background 0.2s ease;
-}
-
-.qualitySelect:hover {
- background: rgba(255, 255, 255, 0.15);
-}
-
-.qualitySelect:focus {
- outline: 2px solid rgba(255, 255, 255, 0.3);
- outline-offset: 2px;
-}
-
-.qualitySelect option {
- background: #1a1a1a;
- color: #fff;
-}
diff --git a/js/hang-ui/tsconfig.json b/js/hang-ui/tsconfig.json
deleted file mode 100644
index b7aa652400..0000000000
--- a/js/hang-ui/tsconfig.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "compilerOptions": {
- "rootDir": ".",
- "target": "ESNext",
- "module": "ESNext",
- "moduleResolution": "bundler",
- "rewriteRelativeImportExtensions": true,
- "strict": true,
- "esModuleInterop": true,
- "allowSyntheticDefaultImports": true,
- "allowImportingTsExtensions": true,
- "jsx": "preserve",
- "jsxImportSource": "solid-js",
- "skipLibCheck": true,
- "outDir": "dist",
- "declaration": true,
- "emitDeclarationOnly": true
- },
- "include": ["src"]
-}
diff --git a/js/hang-ui/vite.config.ts b/js/hang-ui/vite.config.ts
deleted file mode 100644
index 33ab016c98..0000000000
--- a/js/hang-ui/vite.config.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { resolve } from "path";
-import { defineConfig } from "vite";
-import solidPlugin from "vite-plugin-solid";
-
-export default defineConfig({
- plugins: [solidPlugin()],
- build: {
- lib: {
- entry: {
- "publish/index": resolve(__dirname, "src/publish/index.tsx"),
- "watch/index": resolve(__dirname, "src/watch/index.tsx"),
- },
- formats: ["es"],
- },
- rollupOptions: {
- external: ["@moq/hang", "@moq/lite", "@moq/signals"],
- },
- sourcemap: true,
- target: "esnext",
- },
-});
diff --git a/js/hang-ui/vitest.config.ts b/js/hang-ui/vitest.config.ts
deleted file mode 100644
index 29591206b3..0000000000
--- a/js/hang-ui/vitest.config.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import solid from "unplugin-solid/vite";
-import { defineConfig } from "vitest/config";
-
-export default defineConfig({
- plugins: [solid()],
- test: {
- environment: "happy-dom",
- globals: true,
- },
-});
diff --git a/js/hang/README.md b/js/hang/README.md
index 15eba0506e..20866ce29c 100644
--- a/js/hang/README.md
+++ b/js/hang/README.md
@@ -7,19 +7,15 @@
[](https://www.npmjs.com/package/@moq/hang)
[](https://www.typescriptlang.org/)
-A TypeScript library for real-time media streaming using [Media over QUIC](https://moq.dev/) (MoQ), supported by modern web browsers.
-
-**`@moq/hang`** provides high-level media components for live audio and video streaming, built on top of [`@moq/lite`](../moq).
-It uses new web APIs like WebCodecs, WebTransport, and Web Components.
-
-> **Note:** This project is a [fork](https://moq.dev/blog/transfork) of the [IETF MoQ specification](https://datatracker.ietf.org/group/moq/documents/), optimized for practical deployment with a narrower focus and exponentially simpler implementation.
+Core media library for [Media over QUIC](https://moq.dev/) (MoQ). Provides shared primitives used by [`@moq/watch`](../watch) and [`@moq/publish`](../publish), built on top of [`@moq/net`](../lite).
## Features
-- 🎥 **Real-time latency** via WebTransport and WebCodecs.
-- 🎵 **Low-level API** for advanced use cases, such as processing individual frames.
-- 🧩 **Web Components** for easy integration.
-- 🔄 **Reactive** Easy to use with React and SolidJS adapters.
+- **Catalog** — JSON track describing other tracks and their codec properties (audio, video, chat, location, etc.)
+- **Container** — Media framing in two formats: CMAF (fMP4) and Legacy (varint-timestamp + raw codec bitstream)
+- **Utilities** — Hex encoding, Opus audio polyfill (libav), latency computation, browser detection workarounds
+
+Browser support detection is provided by [``](../watch) and [``](../publish).
## Installation
@@ -31,217 +27,39 @@ yarn add @moq/hang
bun add @moq/hang
```
-## Web Components (Easiest)
-
-The fastest way to add MoQ to your web page.
-Check out the [hang-demo](../hang-demo) folder for working examples.
-
-There's also a Javascript API for more advanced use cases; see below.
-
-```html
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-### Tree-Shaking
-Javascript bundlers often perform dead code elimination.
-This can have unfortunate side effects, as it can remove the code that registers these components.
-
-To attempt to mitigate this, you have to explicitly import components with the `/element` suffix.
-Your bundler *should* be smart enough to avoid tree-shaking but you may need to `export` any types just to ensure they are not removed.
-
-### Attributes
-All of the web components support setting HTTML attributes and Javascript properties.
-...what's the difference?
-
-HTML Attributes are strings.
-Javacript properties are typed and reactive.
-
-` ` will work, but it's not type-safe.
-You can use DOM callbacks to detect when the attribute changes but it's not as convenient.
-
-Alternatively, you could perform the same thing with Javascript properties:
-```tsx
-const watch = document.querySelector("hang-watch") as HangWatch;
-watch.volume.set(0.8);
-```
-
-This will actually set the `volume="0.8"` attribute on the element mostly because it's cool and useful when debugging.
-But it's also useful because you can use the `.subscribe` method to receive an event on change.
-
-
-### ``
-
-Subscribes to a hang broadcast and renders it.
-
-**Attributes:**
-- `url` (required): The URL of the server, potentially authenticated via a `?jwt` token.
-- `name` (required): The name of the broadcast.
-- `controls`: Show simple playback controls.
-- `paused`: Pause playback.
-- `muted`: Mute audio playback.
-- `volume`: Set the audio volume, only when `!muted`.
-
-
-```html
-
-
-
-
-
-
-
-```
-
-
-### ``
-
-Publishes a microphone/camera or screen as a hang broadcast.
+## JavaScript API
-**Attributes:**
-- `url` (required): The URL of the server, potentially authenticated via a `?jwt` token.
-- `name` (required): The name of the broadcast.
-- `device`: "camera" or "screen".
-- `audio`: Enable audio capture.
-- `video`: Enable video capture
-- `controls`: Show simple publishing controls
-
-```html
-
-
-
-
-
-
-```
-
-### ``
-
-Downloads multiple hang broadcasts and renders them in a grid.
-Very crude and best as an example; use the JS API instead.
-
-```html
-
-
-
-
-```
-
-This will discover any broadcasts that start with `room123/` and render them.
-You can also specify a `` child element to publish your own broadcast, using a local preview instead of downloading it.
-
-### ``
+```typescript
+import * as Hang from "@moq/hang";
-A simple element that displays browser support.
+// Catalog — describes tracks and their codec properties
+import * as Catalog from "@moq/hang/catalog";
-```html
-
+// Container — media framing (CMAF and Legacy formats)
+import * as Container from "@moq/hang/container";
-
-
+// CMAF (fMP4) and Legacy (varint-timestamp + raw bitstream) are both available:
+// Container.Cmaf — createVideoInitSegment, createAudioInitSegment, encodeDataSegment, decodeDataSegment, etc.
+// Container.Legacy — Producer / Consumer classes
```
-
-## Javascript API
-
-**NOTE** This API is still evolving and may change in the future.
-You're on your own when it comes to documentation... for now.
+For watching and publishing, use the dedicated packages:
```typescript
-import * as Hang from "@moq/hang";
-
-// Create a new connection, available via `.established`
-const connection = new Hang.Connection("https://cdn.moq.dev/anon");
-
-// Publishing media, with (optional) initial settings
-const publish = new Hang.Publish.Broadcast(connection, {
- enabled: true,
- name: "bob",
- video: { enabled: true, device: "camera" },
-});
-
-// Subscribing to media, with (optional) initial settings
-const watch = new Hang.Watch.Broadcast(connection, {
- enabled: true,
- name: "bob",
- video: { enabled: true },
-});
-
-// Note that virtually everything is reactive, so you can change settings at any time.
-publish.name.set("alice");
-watch.audio.enabled.set(true);
+import * as Watch from "@moq/watch";
+import * as Publish from "@moq/publish";
```
-## Browser Compatibility
-
-This library requires modern browser features.
-We're currently only testing the most recent versions of Chrome and sometimes Firefox.
+## Related Packages
-## Framework Integration
-
-The Reactive API contains helpers to convert into React and SolidJS signals:
-
-```ts
-import react from "@moq/signals/react";
-// same for solid
-
-const publish = document.querySelector("hang-publish") as HangPublish;
-const media = react(publish.video.media);
-
-/// Now you have a `react` signal that changes when the video source changes.
-useEffect(() => {
- video.srcObject = media();
-}, [media]);
-```
+- **[@moq/watch](../watch)** — Subscribe to and render MoQ broadcasts
+- **[@moq/publish](../publish)** — Publish media to MoQ broadcasts
+- **[@moq/net](../lite)** — Core pub/sub transport protocol
+- **[@moq/signals](../signals)** — Reactive signals library
## License
Licensed under either:
-- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
-- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
+- Apache License, Version 2.0 ([LICENSE-APACHE](../../LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
+- MIT license ([LICENSE-MIT](../../LICENSE-MIT) or http://opensource.org/licenses/MIT)
diff --git a/js/hang/package.json b/js/hang/package.json
index c1e162c1bd..cbbc990e59 100644
--- a/js/hang/package.json
+++ b/js/hang/package.json
@@ -1,49 +1,37 @@
{
"name": "@moq/hang",
"type": "module",
- "version": "0.1.1",
- "description": "Media over QUIC library",
+ "version": "0.2.7",
+ "description": "WebCodecs-based media format for MoQ",
"license": "(MIT OR Apache-2.0)",
"repository": "github:moq-dev/moq",
+ "sideEffects": false,
"exports": {
".": "./src/index.ts",
- "./publish": "./src/publish/index.ts",
- "./publish/element": "./src/publish/element.ts",
- "./watch": "./src/watch/index.ts",
- "./watch/element": "./src/watch/element.ts",
- "./meet": "./src/meet/index.ts",
- "./meet/element": "./src/meet/element.ts",
"./catalog": "./src/catalog/index.ts",
- "./support": "./src/support/index.ts",
- "./support/element": "./src/support/element.ts"
+ "./container": "./src/container/index.ts",
+ "./util": "./src/util/index.ts"
},
- "sideEffects": [
- "./src/publish/element.ts",
- "./src/watch/element.ts",
- "./src/support/element.ts",
- "./src/meet/element.ts"
- ],
"scripts": {
- "build": "rimraf dist && tsc -b && bun ../scripts/package.ts",
+ "build": "rimraf dist && tsc -b && bun ../common/package.ts",
"check": "tsc --noEmit",
- "test": "vitest",
- "release": "bun ../scripts/release.ts"
+ "release": "bun ../common/release.ts"
},
"dependencies": {
"@kixelated/libavjs-webcodecs-polyfill": "^0.5.5",
- "@moq/lite": "workspace:^",
- "@moq/signals": "workspace:^",
"@libav.js/variant-opus-af": "^6.8.8",
- "async-mutex": "^0.5.0",
- "comlink": "^4.4.2",
+ "@moq/loc": "workspace:^",
+ "@moq/net": "workspace:^",
+ "@moq/signals": "workspace:^",
+ "@svta/cml-iso-bmff": "^1.0.0-alpha.9",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/audioworklet": "^0.0.77",
+ "@types/bun": "^1.3.11",
"@typescript/lib-dom": "npm:@types/web@^0.0.241",
"fast-glob": "^3.3.3",
"rimraf": "^6.0.1",
- "typescript": "^5.9.2",
- "vitest": "^3.2.4"
+ "typescript": "^6.0.0"
}
}
diff --git a/js/hang/src/catalog/audio.ts b/js/hang/src/catalog/audio.ts
index e57710bf2e..47178addce 100644
--- a/js/hang/src/catalog/audio.ts
+++ b/js/hang/src/catalog/audio.ts
@@ -1,11 +1,10 @@
-import { z } from "zod";
-import { ContainerSchema, DEFAULT_CONTAINER } from "./container";
+import * as z from "zod/mini";
+import { ContainerSchema } from "./container";
import { u53Schema } from "./integers";
// Backwards compatibility: old track schema
const TrackSchema = z.object({
name: z.string(),
- priority: z.number().int().min(0).max(255),
});
// Mirrors AudioDecoderConfig
@@ -14,14 +13,13 @@ export const AudioConfigSchema = z.object({
// See: https://w3c.github.io/webcodecs/codec_registry.html
codec: z.string(),
- // Container format for timestamp encoding
- // Defaults to "legacy" when not specified in catalog (backward compatibility)
- container: ContainerSchema.default(DEFAULT_CONTAINER),
+ // The container format, used to decode the timestamp and more.
+ container: ContainerSchema,
// The description is used for some codecs.
// If provided, we can initialize the decoder based on the catalog alone.
// Otherwise, the initialization information is in-band.
- description: z.string().optional(), // hex encoded TODO use base64
+ description: z.optional(z.string()), // hex encoded TODO use base64
// The sample rate of the audio in Hz
sampleRate: u53Schema,
@@ -31,30 +29,34 @@ export const AudioConfigSchema = z.object({
// The bitrate of the audio in bits per second
// TODO: Support up to Number.MAX_SAFE_INTEGER
- bitrate: u53Schema.optional(),
+ bitrate: z.optional(u53Schema),
+
+ // The maximum jitter before the next frame is emitted in milliseconds.
+ // The player's jitter buffer should be larger than this value.
+ // If not provided, the player should assume each frame is flushed immediately.
+ //
+ // NOTE: The audio "frame" duration depends on the codec, sample rate, etc.
+ // ex: AAC often uses 1024 samples per frame, so at 44100Hz, this would be 1024/44100 = 23ms
+ jitter: z.optional(u53Schema),
});
-export const AudioSchema = z
- .object({
+export const AudioSchema = z.union([
+ z.object({
// A map of track name to rendition configuration.
// This is not an array so it will work with JSON Merge Patch.
renditions: z.record(z.string(), AudioConfigSchema),
-
- // The priority of the audio track, relative to other tracks in the broadcast.
- priority: z.number().int().min(0).max(255),
- })
- .or(
- // Backwards compatibility: transform old {track, config} format to new object format
- z
- .object({
- track: TrackSchema,
- config: AudioConfigSchema,
- })
- .transform((old) => ({
- renditions: { [old.track.name]: old.config },
- priority: old.track.priority,
- })),
- );
+ }),
+ // Backwards compatibility: transform old {track, config} format to new object format
+ z.pipe(
+ z.object({
+ track: TrackSchema,
+ config: AudioConfigSchema,
+ }),
+ z.transform((old) => ({
+ renditions: { [old.track.name]: old.config },
+ })),
+ ),
+]);
export type Audio = z.infer;
export type AudioConfig = z.infer;
diff --git a/js/hang/src/catalog/capabilities.ts b/js/hang/src/catalog/capabilities.ts
index 785006ef7c..b1f4df8ce6 100644
--- a/js/hang/src/catalog/capabilities.ts
+++ b/js/hang/src/catalog/capabilities.ts
@@ -1,20 +1,20 @@
-import { z } from "zod";
+import * as z from "zod/mini";
export const VideoCapabilitiesSchema = z.object({
- hardware: z.array(z.string()).optional(),
- software: z.array(z.string()).optional(),
- unsupported: z.array(z.string()).optional(),
+ hardware: z.optional(z.array(z.string())),
+ software: z.optional(z.array(z.string())),
+ unsupported: z.optional(z.array(z.string())),
});
export const AudioCapabilitiesSchema = z.object({
- hardware: z.array(z.string()).optional(),
- software: z.array(z.string()).optional(),
- unsupported: z.array(z.string()).optional(),
+ hardware: z.optional(z.array(z.string())),
+ software: z.optional(z.array(z.string())),
+ unsupported: z.optional(z.array(z.string())),
});
export const CapabilitiesSchema = z.object({
- video: VideoCapabilitiesSchema.optional(),
- audio: AudioCapabilitiesSchema.optional(),
+ video: z.optional(VideoCapabilitiesSchema),
+ audio: z.optional(AudioCapabilitiesSchema),
});
export type Capabilities = z.infer;
diff --git a/js/hang/src/catalog/chat.ts b/js/hang/src/catalog/chat.ts
index 9950fa3eba..d012b96fef 100644
--- a/js/hang/src/catalog/chat.ts
+++ b/js/hang/src/catalog/chat.ts
@@ -1,9 +1,9 @@
-import { z } from "zod";
+import * as z from "zod/mini";
import { TrackSchema } from "./track";
export const ChatSchema = z.object({
- message: TrackSchema.optional(),
- typing: TrackSchema.optional(),
+ message: z.optional(TrackSchema),
+ typing: z.optional(TrackSchema),
});
export type Chat = z.infer;
diff --git a/js/hang/src/catalog/container.ts b/js/hang/src/catalog/container.ts
index 05b0e81db2..0ba3cd7c3e 100644
--- a/js/hang/src/catalog/container.ts
+++ b/js/hang/src/catalog/container.ts
@@ -1,18 +1,34 @@
-import { z } from "zod";
+import * as z from "zod/mini";
+import { u53Schema } from "./integers";
/**
- * Container format for frame timestamp encoding.
+ * Container format for frame timestamp encoding and frame payload structure.
*
- * - "legacy": Uses QUIC VarInt encoding (1-8 bytes, variable length)
- * - "raw": Uses fixed u64 encoding (8 bytes, big-endian)
- * - "fmp4": Fragmented MP4 container (future)
+ * - "legacy": QUIC VarInt timestamp prefix followed by the raw codec payload.
+ * Timestamps are in microseconds.
+ * - "cmaf": Fragmented MP4 container - frames contain complete moof+mdat fragments.
+ * The init segment (ftyp+moov) is base64-encoded in the catalog.
+ * - "loc": Low Overhead Container (draft-ietf-moq-loc). Each frame has a small
+ * property block followed by the codec payload.
*/
-export const ContainerSchema = z.enum(["legacy", "raw", "fmp4"]);
+export const ContainerSchema = z._default(
+ z.discriminatedUnion("kind", [
+ // The default hang container
+ z.object({ kind: z.literal("legacy") }),
+ // CMAF container with base64-encoded init segment (ftyp+moov).
+ // `timescale` and `trackId` are deprecated: they duplicate info in `init`
+ // and are accepted only so catalogs from newer publishers (which still
+ // emit them for older players) round-trip cleanly.
+ z.object({
+ kind: z.literal("cmaf"),
+ init: z.base64(),
+ timescale: z.optional(u53Schema),
+ trackId: z.optional(u53Schema),
+ }),
+ // Low Overhead Container.
+ z.object({ kind: z.literal("loc") }),
+ ]),
+ { kind: "legacy" },
+);
export type Container = z.infer;
-
-/**
- * Default container format when not specified.
- * Set to legacy for backward compatibility.
- */
-export const DEFAULT_CONTAINER: Container = "legacy";
diff --git a/js/hang/src/catalog/format.ts b/js/hang/src/catalog/format.ts
new file mode 100644
index 0000000000..471a94604f
--- /dev/null
+++ b/js/hang/src/catalog/format.ts
@@ -0,0 +1,19 @@
+// Filename-style format extensions for broadcast names.
+//
+// Broadcast names use a filename-style suffix to advertise their catalog format,
+// e.g. `demo/bbb.hang` or `demo/bbb.msf`. Consumers parse the suffix to pick a
+// catalog track without explicit configuration; publishers should include the
+// suffix in the name they publish so consumers can detect it.
+
+export const FORMATS = ["hang", "msf"] as const;
+export type Format = (typeof FORMATS)[number];
+
+export const DEFAULT_FORMAT: Format = "hang";
+
+/** Detect the catalog format from a broadcast name suffix, or `undefined` if the name has no recognized extension. */
+export function detectFormat(name: string): Format | undefined {
+ for (const format of FORMATS) {
+ if (name.endsWith(`.${format}`)) return format;
+ }
+ return undefined;
+}
diff --git a/js/hang/src/catalog/index.ts b/js/hang/src/catalog/index.ts
index cfe27be115..e63ea4f1a9 100644
--- a/js/hang/src/catalog/index.ts
+++ b/js/hang/src/catalog/index.ts
@@ -2,10 +2,11 @@ export * from "./audio";
export * from "./capabilities";
export * from "./chat";
export * from "./container";
-export { DEFAULT_CONTAINER } from "./container";
+export * from "./format";
export * from "./integers";
export * from "./location";
export * from "./preview";
+export * from "./priority";
export * from "./root";
export * from "./track";
export * from "./user";
diff --git a/js/hang/src/catalog/integers.ts b/js/hang/src/catalog/integers.ts
index 677c0cb879..f8d6121505 100644
--- a/js/hang/src/catalog/integers.ts
+++ b/js/hang/src/catalog/integers.ts
@@ -1,9 +1,9 @@
-import { z } from "zod";
+import * as z from "zod/mini";
/**
* Branded type for 8-bit unsigned integers (0-255)
*/
-export const u8Schema = z.number().int().nonnegative().max(255).brand("u8");
+export const u8Schema = z.number().check(z.int(), z.nonnegative(), z.lte(255)).brand("u8");
export type U8 = z.infer;
@@ -11,7 +11,7 @@ export type U8 = z.infer;
* Branded type for 53-bit unsigned integers (JavaScript's MAX_SAFE_INTEGER)
* This represents the maximum safe integer in JavaScript (2^53 - 1)
*/
-export const u53Schema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).brand("u53");
+export const u53Schema = z.number().check(z.int(), z.nonnegative(), z.lte(Number.MAX_SAFE_INTEGER)).brand("u53");
export type U53 = z.infer;
diff --git a/js/hang/src/catalog/location.ts b/js/hang/src/catalog/location.ts
index e7eb014fd9..52055e5117 100644
--- a/js/hang/src/catalog/location.ts
+++ b/js/hang/src/catalog/location.ts
@@ -1,41 +1,41 @@
-import { z } from "zod";
+import * as z from "zod/mini";
import { TrackSchema } from "./track";
export const PositionSchema = z.object({
// The relative X position of the broadcast, from -1 to +1.
// This should be used for audio panning but can also be used for video positioning.
- x: z.number().optional(),
+ x: z.optional(z.number()),
// The relative Y position of the broadcast, from -1 to +1.
// This can be used for video positioning, and maybe audio panning.
- y: z.number().optional(),
+ y: z.optional(z.number()),
// The relative Z index of the broadcast, where larger values are closer to the viewer.
// This is used to break ties when there are multiple broadcasts at the same position.
- z: z.number().optional(),
+ z: z.optional(z.number()),
// The scale of the broadcast, where 1 is 100%
- s: z.number().optional(),
+ s: z.optional(z.number()),
});
export const LocationSchema = z.object({
// The initial position of the broadcaster, from -1 to +1 in both dimensions.
// If not provided, then the broadcaster is assumed to be at (0,0)
// This should be used for audio panning but can also be used for video positioning.
- initial: PositionSchema.optional(),
+ initial: z.optional(PositionSchema),
// If provided, then updates to the position are done via a separate Moq track.
// This is used to avoid a full catalog update every time we want to update a few bytes.
// TODO: These updates currently use JSON for simplicity, but we should use a binary format.
- track: TrackSchema.optional(),
+ track: z.optional(TrackSchema),
// If set, then this broadcaster allows other peers to request position updates via this handle.
// We will have to discover and subscribe to their position updates.
- handle: z.string().optional(),
+ handle: z.optional(z.string()),
// If provided, this broadcaster is signaling the location of other peers.
// The payload is a JSON blob keyed by handle for each peer.
- peers: TrackSchema.optional(),
+ peers: z.optional(TrackSchema),
});
export type Location = z.infer;
diff --git a/js/hang/src/catalog/preview.ts b/js/hang/src/catalog/preview.ts
index 75e2207150..ae8183b074 100644
--- a/js/hang/src/catalog/preview.ts
+++ b/js/hang/src/catalog/preview.ts
@@ -1,15 +1,15 @@
-import { z } from "zod";
+import * as z from "zod/mini";
export const PreviewSchema = z.object({
- name: z.string().optional(), // name
- avatar: z.string().optional(), // avatar
+ name: z.optional(z.string()), // name
+ avatar: z.optional(z.string()), // avatar
- audio: z.boolean().optional(), // audio enabled
- video: z.boolean().optional(), // video enabled
+ audio: z.optional(z.boolean()), // audio enabled
+ video: z.optional(z.boolean()), // video enabled
- typing: z.boolean().optional(), // actively typing
- chat: z.boolean().optional(), // chatted recently
- screen: z.boolean().optional(), // screen sharing
+ typing: z.optional(z.boolean()), // actively typing
+ chat: z.optional(z.boolean()), // chatted recently
+ screen: z.optional(z.boolean()), // screen sharing
});
export type Preview = z.infer;
diff --git a/js/hang/src/publish/priority.ts b/js/hang/src/catalog/priority.ts
similarity index 100%
rename from js/hang/src/publish/priority.ts
rename to js/hang/src/catalog/priority.ts
diff --git a/js/hang/src/catalog/root.ts b/js/hang/src/catalog/root.ts
index 7c9684c3e7..22b4bbe3c9 100644
--- a/js/hang/src/catalog/root.ts
+++ b/js/hang/src/catalog/root.ts
@@ -1,5 +1,5 @@
-import type * as Moq from "@moq/lite";
-import { z } from "zod";
+import type * as Moq from "@moq/net";
+import * as z from "zod/mini";
import { AudioSchema } from "./audio";
import { CapabilitiesSchema } from "./capabilities";
@@ -10,13 +10,13 @@ import { UserSchema } from "./user";
import { VideoSchema } from "./video";
export const RootSchema = z.object({
- video: VideoSchema.optional(),
- audio: AudioSchema.optional(),
- location: LocationSchema.optional(),
- user: UserSchema.optional(),
- chat: ChatSchema.optional(),
- capabilities: CapabilitiesSchema.optional(),
- preview: TrackSchema.optional(),
+ video: z.optional(VideoSchema),
+ audio: z.optional(AudioSchema),
+ location: z.optional(LocationSchema),
+ user: z.optional(UserSchema),
+ chat: z.optional(ChatSchema),
+ capabilities: z.optional(CapabilitiesSchema),
+ preview: z.optional(TrackSchema),
});
export type Root = z.infer;
diff --git a/js/hang/src/catalog/track.ts b/js/hang/src/catalog/track.ts
index 7eb18e5ac3..d7ccbd586f 100644
--- a/js/hang/src/catalog/track.ts
+++ b/js/hang/src/catalog/track.ts
@@ -1,7 +1,6 @@
-import { z } from "zod";
+import * as z from "zod/mini";
export const TrackSchema = z.object({
name: z.string(),
- priority: z.number().int().min(0).max(255),
});
export type Track = z.infer;
diff --git a/js/hang/src/catalog/user.ts b/js/hang/src/catalog/user.ts
index eab22b099b..7f4a699c2a 100644
--- a/js/hang/src/catalog/user.ts
+++ b/js/hang/src/catalog/user.ts
@@ -1,10 +1,10 @@
-import { z } from "zod";
+import * as z from "zod/mini";
export const UserSchema = z.object({
- id: z.string().optional(),
- name: z.string().optional(),
- avatar: z.string().optional(), // TODO allow using a track instead of a URL?
- color: z.string().optional(),
+ id: z.optional(z.string()),
+ name: z.optional(z.string()),
+ avatar: z.optional(z.string()), // TODO allow using a track instead of a URL?
+ color: z.optional(z.string()),
});
export type User = z.infer;
diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts
index b8b77883ff..dffb5e7cb1 100644
--- a/js/hang/src/catalog/video.ts
+++ b/js/hang/src/catalog/video.ts
@@ -1,11 +1,10 @@
-import { z } from "zod";
-import { ContainerSchema, DEFAULT_CONTAINER } from "./container";
+import * as z from "zod/mini";
+import { ContainerSchema } from "./container";
import { u53Schema } from "./integers";
// Backwards compatibility: old track schema
const TrackSchema = z.object({
name: z.string(),
- priority: z.number().int().min(0).max(255),
});
// Based on VideoDecoderConfig
@@ -13,89 +12,94 @@ export const VideoConfigSchema = z.object({
// See: https://w3c.github.io/webcodecs/codec_registry.html
codec: z.string(),
- // Container format for timestamp encoding
- // Defaults to "legacy" when not specified in catalog (backward compatibility)
- container: ContainerSchema.default(DEFAULT_CONTAINER),
+ // The container format, used to decode the timestamp and more.
+ container: ContainerSchema,
// The description is used for some codecs.
// If provided, we can initialize the decoder based on the catalog alone.
// Otherwise, the initialization information is (repeated) before each key-frame.
- description: z.string().optional(), // hex encoded TODO use base64
+ description: z.optional(z.string()), // hex encoded TODO use base64
// The width and height of the video in pixels.
// NOTE: formats that don't use a description can adjust these values in-band.
- codedWidth: u53Schema.optional(),
- codedHeight: u53Schema.optional(),
+ codedWidth: z.optional(u53Schema),
+ codedHeight: z.optional(u53Schema),
// Ratio of display width/height to coded width/height
// Allows stretching/squishing individual "pixels" of the video
// If not provided, the display ratio is 1:1
- displayAspectWidth: u53Schema.optional(),
- displayAspectHeight: u53Schema.optional(),
+ displayAspectWidth: z.optional(u53Schema),
+ displayAspectHeight: z.optional(u53Schema),
// The frame rate of the video in frames per second
- framerate: z.number().optional(),
+ framerate: z.optional(z.number()),
// The bitrate of the video in bits per second
// TODO: Support up to Number.MAX_SAFE_INTEGER
- bitrate: u53Schema.optional(),
+ bitrate: z.optional(u53Schema),
// If true, the decoder will optimize for latency.
// Default: true
- optimizeForLatency: z.boolean().optional(),
+ optimizeForLatency: z.optional(z.boolean()),
+
+ // The maximum jitter before the next frame is emitted in milliseconds.
+ // The player's jitter buffer should be larger than this value.
+ // If not provided, the player should assume each frame is flushed immediately.
+ //
+ // ex:
+ // - If each frame is flushed immediately, this would be 1000/fps.
+ // - If there can be up to 3 b-frames in a row, this would be 3 * 1000/fps.
+ // - If frames are buffered into 2s segments, this would be 2s.
+ jitter: z.optional(u53Schema),
});
// Mirrors VideoDecoderConfig
// https://w3c.github.io/webcodecs/#video-decoder-config
-export const VideoSchema = z
- .object({
+export const VideoSchema = z.union([
+ z.object({
// A map of track name to rendition configuration.
// This is not an array in order for it to work with JSON Merge Patch.
renditions: z.record(z.string(), VideoConfigSchema),
- // The priority of the video track, relative to other tracks in the broadcast.
- priority: z.number().int().min(0).max(255),
-
// Render the video at this size in pixels.
// This is separate from the display aspect ratio because it does not require reinitialization.
- display: z
- .object({
+ display: z.optional(
+ z.object({
width: u53Schema,
height: u53Schema,
- })
- .optional(),
+ }),
+ ),
// The rotation of the video in degrees.
// Default: 0
- rotation: z.number().optional(),
+ rotation: z.optional(z.number()),
// If true, the decoder will flip the video horizontally
// Default: false
- flip: z.boolean().optional(),
- })
- .or(
- // Backwards compatibility: transform old array of {track, config} to new object format
- z
- .array(
- z.object({
- track: TrackSchema,
- config: VideoConfigSchema,
- }),
- )
- .transform((arr) => {
- const config = arr[0]?.config;
- return {
- renditions: Object.fromEntries(arr.map((item) => [item.track.name, item.config])),
- priority: arr[0]?.track.priority ?? 128,
- display:
- config?.displayAspectWidth && config?.displayAspectHeight
- ? { width: config.displayAspectWidth, height: config.displayAspectHeight }
- : undefined,
- rotation: undefined,
- flip: undefined,
- };
+ flip: z.optional(z.boolean()),
+ }),
+ // Backwards compatibility: transform old array of {track, config} to new object format
+ z.pipe(
+ z.array(
+ z.object({
+ track: TrackSchema,
+ config: VideoConfigSchema,
}),
- );
+ ),
+ z.transform((arr) => {
+ const config = arr[0]?.config;
+ return {
+ renditions: Object.fromEntries(arr.map((item) => [item.track.name, item.config])),
+ display:
+ config?.displayAspectWidth !== undefined && config?.displayAspectHeight !== undefined
+ ? { width: config.displayAspectWidth, height: config.displayAspectHeight }
+ : undefined,
+ rotation: undefined,
+ flip: undefined,
+ };
+ }),
+ ),
+]);
export type Video = z.infer;
export type VideoConfig = z.infer;
diff --git a/js/hang/src/container/cmaf/decode.ts b/js/hang/src/container/cmaf/decode.ts
new file mode 100644
index 0000000000..e315b816e6
--- /dev/null
+++ b/js/hang/src/container/cmaf/decode.ts
@@ -0,0 +1,410 @@
+/**
+ * MP4 decoding utilities for parsing fMP4 init and data segments.
+ * Used by WebCodecs to extract raw frames from CMAF container.
+ */
+
+import type { Time } from "@moq/net";
+import {
+ type MediaHeaderBox,
+ type ParsedIsoBox,
+ readAvc1,
+ readHev1,
+ readHvc1,
+ readIsoBoxes,
+ readMdat,
+ readMdhd,
+ readMfhd,
+ readMp4a,
+ readStsd,
+ readTfdt,
+ readTfhd,
+ readTkhd,
+ readTrex,
+ readTrun,
+ type SampleDescriptionBox,
+ type TrackExtendsBox,
+ type TrackFragmentBaseMediaDecodeTimeBox,
+ type TrackFragmentHeaderBox,
+ type TrackRunBox,
+ type TrackRunSample,
+} from "@svta/cml-iso-bmff";
+
+// Configure readers for specific box types we need to parse
+const INIT_READERS = {
+ avc1: readAvc1,
+ avc3: readAvc1, // avc3 has same structure
+ hvc1: readHvc1,
+ hev1: readHev1,
+ mp4a: readMp4a,
+ stsd: readStsd,
+ mdhd: readMdhd,
+ tkhd: readTkhd,
+ trex: readTrex,
+};
+
+const DATA_READERS = {
+ mfhd: readMfhd,
+ tfhd: readTfhd,
+ tfdt: readTfdt,
+ trun: readTrun,
+ mdat: readMdat,
+};
+
+/**
+ * Recursively find a box by type in the box tree.
+ * This is more reliable than the library's findIsoBox which may not traverse all children.
+ */
+function findBox(
+ boxes: ParsedIsoBox[],
+ predicate: (box: ParsedIsoBox) => box is T,
+): T | undefined {
+ for (const box of boxes) {
+ if (predicate(box)) {
+ return box;
+ }
+ // Recursively search children - boxes may have a 'boxes' property with children
+ // biome-ignore lint/suspicious/noExplicitAny: ISO box structure varies
+ const children = (box as any).boxes;
+ if (children && Array.isArray(children)) {
+ const found = findBox(children, predicate);
+ if (found) return found;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Result of parsing an init segment.
+ */
+export interface InitSegment {
+ /** Codec-specific description (avcC, hvcC, esds, dOps, etc.) */
+ description?: Uint8Array;
+ /** Time units per second */
+ timescale: number;
+ /** Track ID from the init segment */
+ trackId: number;
+ /** Default sample duration from moov/mvex/trex, used when not overridden in tfhd/trun. */
+ defaultSampleDuration: number;
+ /** Default sample size from moov/mvex/trex, used when not overridden in tfhd/trun. */
+ defaultSampleSize: number;
+ /**
+ * Default sample flags from moov/mvex/trex, used when not overridden in tfhd/trun.
+ *
+ * Some encoders (notably gstreamer/ffmpeg passthrough) only set sample flags
+ * in trex, leaving tfhd defaults and per-sample trun flags zero. Without this
+ * fallback every sample would appear as a sync sample.
+ */
+ defaultSampleFlags: number;
+}
+
+/**
+ * A decoded sample from a data segment.
+ */
+export interface Sample {
+ /** Raw sample data */
+ data: Uint8Array;
+ /** Timestamp in microseconds */
+ timestamp: number;
+ /** Whether this is a keyframe (sync sample) */
+ keyframe: boolean;
+}
+
+// Helper to convert Uint8Array to ArrayBuffer for the library
+function toArrayBuffer(data: Uint8Array): ArrayBuffer {
+ // Create a new ArrayBuffer and copy data to avoid SharedArrayBuffer issues
+ const buffer = new ArrayBuffer(data.byteLength);
+ new Uint8Array(buffer).set(data);
+ return buffer;
+}
+
+// Type guard for finding boxes by type
+function isBoxType(type: string) {
+ return (box: ParsedIsoBox): box is T => box.type === type;
+}
+
+/**
+ * Parse an init segment (ftyp + moov) to extract codec description and timescale.
+ *
+ * @param init - The init segment data
+ * @returns Parsed init segment information
+ */
+export function decodeInitSegment(init: Uint8Array): InitSegment {
+ // Cast to ParsedIsoBox[] since the library's return type changes with readers
+ const boxes = readIsoBoxes(toArrayBuffer(init), { readers: INIT_READERS }) as ParsedIsoBox[];
+
+ // Find moov > trak > mdia > mdhd for timescale
+ const mdhd = findBox(boxes, isBoxType("mdhd"));
+ if (!mdhd) {
+ throw new Error("No mdhd box found in init segment");
+ }
+
+ // Find moov > trak > tkhd for track ID
+ // biome-ignore lint/suspicious/noExplicitAny: ISO box traversal
+ const tkhd = findBox(boxes, isBoxType("tkhd"));
+ const trackId = tkhd?.trackId ?? 1;
+
+ // Find moov > trak > mdia > minf > stbl > stsd for sample description
+ const stsd = findBox(boxes, isBoxType("stsd"));
+ if (!stsd?.entries || stsd.entries.length === 0) {
+ throw new Error("No stsd box found in init segment");
+ }
+
+ // Extract codec-specific description from the first sample entry
+ const entry = stsd.entries[0];
+ const description = extractDescription(entry);
+
+ // Find moov > mvex > trex for this track to extract default sample values.
+ // These are the bottom of the fallback chain when tfhd/trun don't specify them.
+ const trex = findBox(
+ boxes,
+ (box): box is TrackExtendsBox & ParsedIsoBox =>
+ box.type === "trex" && (box as TrackExtendsBox).trackId === trackId,
+ );
+
+ return {
+ description,
+ timescale: mdhd.timescale,
+ trackId,
+ defaultSampleDuration: trex?.defaultSampleDuration ?? 0,
+ defaultSampleSize: trex?.defaultSampleSize ?? 0,
+ defaultSampleFlags: trex?.defaultSampleFlags ?? 0,
+ };
+}
+
+/**
+ * Extract codec-specific description from a sample entry.
+ * The description is codec-specific: avcC for H.264, hvcC for H.265, esds for AAC, dOps for Opus.
+ */
+// biome-ignore lint/suspicious/noExplicitAny: ISO box types vary
+function extractDescription(entry: any): Uint8Array | undefined {
+ if (!entry.boxes || !Array.isArray(entry.boxes)) {
+ return undefined;
+ }
+
+ // Look for codec config boxes in the sample entry
+ for (const box of entry.boxes) {
+ // Handle raw Uint8Array boxes (already serialized)
+ if (box instanceof Uint8Array) {
+ // Extract the payload without the box header (8 bytes: 4 size + 4 type)
+ if (box.length > 8) {
+ // Check if this looks like a codec config box by reading the type
+ const typeBytes = String.fromCharCode(box[4], box[5], box[6], box[7]);
+ if (typeBytes === "avcC" || typeBytes === "hvcC" || typeBytes === "dOps") {
+ return new Uint8Array(box.slice(8));
+ }
+ if (typeBytes === "esds") {
+ // esds payload has nested descriptors; extract the AudioSpecificConfig (tag 0x05).
+ return extractAudioSpecificConfig(new Uint8Array(box.slice(8)));
+ }
+ }
+ continue;
+ }
+
+ // Check for known codec config box types
+ const boxType = box.type;
+ if (boxType === "avcC" || boxType === "hvcC" || boxType === "dOps") {
+ if (box.view) {
+ const view = box.view;
+ const headerSize = 8;
+ const payloadOffset = view.byteOffset + headerSize;
+ const payloadLength = box.size - headerSize;
+ return new Uint8Array(view.buffer, payloadOffset, payloadLength);
+ }
+ if (box.data instanceof Uint8Array) {
+ return new Uint8Array(box.data);
+ }
+ if (box.raw instanceof Uint8Array) {
+ return new Uint8Array(box.raw.slice(8));
+ }
+ }
+ if (boxType === "esds") {
+ let payload: Uint8Array | undefined;
+ if (box.view) {
+ const view = box.view;
+ const headerSize = 8;
+ payload = new Uint8Array(view.buffer, view.byteOffset + headerSize, box.size - headerSize);
+ } else if (box.data instanceof Uint8Array) {
+ payload = new Uint8Array(box.data);
+ } else if (box.raw instanceof Uint8Array) {
+ payload = new Uint8Array(box.raw.slice(8));
+ }
+ if (payload) return extractAudioSpecificConfig(payload);
+ }
+ }
+
+ return undefined;
+}
+
+/**
+ * Extract AudioSpecificConfig from an esds box payload.
+ * The esds contains nested descriptors: ES_Descriptor (0x03) → DecoderConfigDescriptor (0x04)
+ * → DecoderSpecificInfo (0x05). The DecoderSpecificInfo payload is the AudioSpecificConfig
+ * that AudioDecoder.configure() expects.
+ */
+function extractAudioSpecificConfig(esds: Uint8Array): Uint8Array | undefined {
+ // Skip version + flags (4 bytes)
+ let offset = 4;
+
+ // Scan for DecoderSpecificInfo tag (0x05)
+ while (offset < esds.length) {
+ const tag = esds[offset++];
+
+ // Parse variable-length size (up to 4 bytes, high bit = continuation)
+ let size = 0;
+ for (let i = 0; i < 4 && offset < esds.length; i++) {
+ const b = esds[offset++];
+ size = (size << 7) | (b & 0x7f);
+ if ((b & 0x80) === 0) break;
+ }
+
+ if (tag === 0x05) {
+ // Found DecoderSpecificInfo — payload is the AudioSpecificConfig
+ if (offset + size <= esds.length) {
+ return new Uint8Array(esds.buffer, esds.byteOffset + offset, size);
+ }
+ return undefined;
+ }
+
+ // For container descriptors (0x03, 0x04), skip their fixed header fields
+ // but continue scanning their children (don't skip the full size).
+ if (tag === 0x03) {
+ offset += 3; // ES_ID (2) + flags (1)
+ } else if (tag === 0x04) {
+ offset += 13; // objectTypeIndication (1) + streamType (1) + bufferSizeDB (3) + maxBitrate (4) + avgBitrate (4)
+ } else {
+ // Unknown tag — skip its payload entirely
+ offset += size;
+ }
+ }
+
+ return undefined;
+}
+
+/**
+ * Extract just the base media decode time from a data segment (moof + mdat).
+ * This is a lighter-weight function when you only need the timestamp.
+ *
+ * @param segment - The moof + mdat data
+ * @param init - Parsed init segment (provides timescale)
+ * @returns The base media decode time in microseconds
+ */
+export function decodeTimestamp(segment: Uint8Array, init: InitSegment): Time.Micro {
+ const boxes = readIsoBoxes(toArrayBuffer(segment), { readers: DATA_READERS }) as ParsedIsoBox[];
+
+ // Find moof > traf > tfdt for base media decode time
+ const tfdt = findBox(boxes, isBoxType("tfdt"));
+ const baseDecodeTime = tfdt?.baseMediaDecodeTime ?? 0;
+
+ // Convert to microseconds
+ return ((baseDecodeTime * 1_000_000) / init.timescale) as Time.Micro;
+}
+
+/**
+ * Parse a data segment (moof + mdat) to extract raw samples.
+ *
+ * Sample duration/size/flags fall back through trun → tfhd → trex (init segment)
+ * per ISO/IEC 14496-12 §8.8.7. The init segment's trex defaults are required for
+ * fragments where the encoder only set them once in moov (e.g. gstreamer passthrough).
+ *
+ * @param segment - The moof + mdat data
+ * @param init - Parsed init segment (provides timescale and trex defaults)
+ * @returns Array of decoded samples
+ */
+export function decodeDataSegment(segment: Uint8Array, init: InitSegment): Sample[] {
+ // Cast to ParsedIsoBox[] since the library's return type changes with readers
+ const boxes = readIsoBoxes(toArrayBuffer(segment), { readers: DATA_READERS }) as ParsedIsoBox[];
+
+ // Find moof > traf > tfdt for base media decode time
+ const tfdt = findBox(boxes, isBoxType("tfdt"));
+ const baseDecodeTime = tfdt?.baseMediaDecodeTime ?? 0;
+
+ // Find moof > traf > tfhd for default sample values, falling back to trex from the init segment.
+ const tfhd = findBox(boxes, isBoxType("tfhd"));
+ const defaultDuration = tfhd?.defaultSampleDuration ?? init.defaultSampleDuration;
+ const defaultSize = tfhd?.defaultSampleSize ?? init.defaultSampleSize;
+ const defaultFlags = tfhd?.defaultSampleFlags ?? init.defaultSampleFlags;
+
+ // Find moof > traf > trun for sample info
+ const trun = findBox(boxes, isBoxType("trun"));
+ if (!trun) {
+ throw new Error("No trun box found in data segment");
+ }
+
+ // Find mdat for sample data
+ // biome-ignore lint/suspicious/noExplicitAny: mdat box type
+ const mdat = findBox(boxes, isBoxType("mdat"));
+ if (!mdat) {
+ throw new Error("No mdat box found in data segment");
+ }
+
+ // mdat.data contains the raw sample data
+ const mdatData = mdat.data as Uint8Array;
+ if (!mdatData) {
+ throw new Error("No data in mdat box");
+ }
+
+ const samples: Sample[] = [];
+
+ // trun.dataOffset is an offset from the base data offset (typically moof start) to the first sample.
+ // For simple CMAF segments where moof is followed immediately by mdat, this equals moof.size + 8.
+ // Since mdat.data is the mdat payload (excluding the 8-byte header), we need to compute the
+ // offset within mdatData. For now, we assume samples start at the beginning of mdat.data
+ // when dataOffset is not specified or when it points to the start of mdat payload.
+ // TODO: For complex cases with base_data_offset in tfhd, this needs additional handling.
+ let dataOffset = 0;
+ let decodeTime = baseDecodeTime;
+
+ for (let i = 0; i < trun.sampleCount; i++) {
+ const sample: TrackRunSample = trun.samples[i] ?? {};
+
+ const sampleSize = sample.sampleSize ?? defaultSize;
+ const sampleDuration = sample.sampleDuration ?? defaultDuration;
+
+ // Validate sample size - must be positive to produce valid data
+ if (sampleSize <= 0) {
+ throw new Error(`Invalid sample size ${sampleSize} for sample ${i} in trun`);
+ }
+
+ // Duration 0 is valid for single-sample CMAF fragments where duration
+ // is implicit. Negative duration would indicate corrupt data.
+ if (sampleDuration < 0) {
+ throw new Error(`Invalid sample duration ${sampleDuration} for sample ${i} in trun`);
+ }
+
+ // Bounds check before slicing to prevent reading past mdat data
+ if (dataOffset + sampleSize > mdatData.length) {
+ throw new Error(
+ `Sample ${i} would overflow mdat: offset=${dataOffset}, size=${sampleSize}, mdatLength=${mdatData.length}`,
+ );
+ }
+
+ const sampleFlags =
+ i === 0 && trun.firstSampleFlags !== undefined
+ ? trun.firstSampleFlags
+ : (sample.sampleFlags ?? defaultFlags);
+ const compositionOffset = sample.sampleCompositionTimeOffset ?? 0;
+
+ // Extract sample data
+ const data = new Uint8Array(mdatData.slice(dataOffset, dataOffset + sampleSize));
+ dataOffset += sampleSize;
+
+ // Calculate presentation timestamp in microseconds
+ // PTS = (decode_time + composition_offset) * 1_000_000 / timescale
+ const pts = decodeTime + compositionOffset;
+ const timestamp = Math.round((pts * 1_000_000) / init.timescale);
+
+ // Check if keyframe (sample_is_non_sync_sample flag is bit 16)
+ // If flag is 0, treat as keyframe for safety
+ const keyframe = sampleFlags === 0 || (sampleFlags & 0x00010000) === 0;
+
+ samples.push({
+ data,
+ timestamp,
+ keyframe,
+ });
+
+ decodeTime += sampleDuration;
+ }
+
+ return samples;
+}
diff --git a/js/hang/src/container/cmaf/encode.ts b/js/hang/src/container/cmaf/encode.ts
new file mode 100644
index 0000000000..99e7bb03d7
--- /dev/null
+++ b/js/hang/src/container/cmaf/encode.ts
@@ -0,0 +1,1059 @@
+/**
+ * MP4 encoding utilities for creating fMP4 init and data segments.
+ * Used by MSE to create init segments (ftyp+moov) and data segments (moof+mdat).
+ */
+
+import {
+ type DataEntryUrlBox,
+ type DataInformationBox,
+ type DataReferenceBox,
+ type DecodingTimeToSampleBox,
+ type FileTypeBox,
+ type HandlerReferenceBox,
+ type IsoBoxStreamable,
+ type IsoBoxWriterMap,
+ type MediaBox,
+ type MediaDataBox,
+ type MediaHeaderBox,
+ type MediaInformationBox,
+ type MovieBox,
+ type MovieExtendsBox,
+ type MovieFragmentBox,
+ type MovieFragmentHeaderBox,
+ type MovieHeaderBox,
+ type SampleDescriptionBox,
+ type SampleTableBox,
+ type SoundMediaHeaderBox,
+ type TrackBox,
+ type TrackExtendsBox,
+ type TrackFragmentBaseMediaDecodeTimeBox,
+ type TrackFragmentBox,
+ type TrackFragmentHeaderBox,
+ type TrackHeaderBox,
+ type TrackRunBox,
+ type VideoMediaHeaderBox,
+ writeDref,
+ writeFtyp,
+ writeHdlr,
+ writeIsoBoxes,
+ writeMdat,
+ writeMdhd,
+ writeMfhd,
+ writeMvhd,
+ writeSmhd,
+ writeStsd,
+ writeStts,
+ writeTfdt,
+ writeTfhd,
+ writeTkhd,
+ writeTrex,
+ writeTrun,
+ writeUrl,
+ writeVmhd,
+} from "@svta/cml-iso-bmff";
+
+import type * as Catalog from "../../catalog";
+import * as Hex from "../../util/hex";
+
+// Identity matrix for tkhd/mvhd (stored as 16.16 fixed point)
+const IDENTITY_MATRIX = [0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000];
+
+// Writers config - maps box types to their writer functions
+const WRITERS: IsoBoxWriterMap = {
+ // Init segment boxes
+ ftyp: writeFtyp,
+ mvhd: writeMvhd,
+ tkhd: writeTkhd,
+ mdhd: writeMdhd,
+ hdlr: writeHdlr,
+ vmhd: writeVmhd,
+ smhd: writeSmhd,
+ "url ": writeUrl,
+ dref: writeDref,
+ stsd: writeStsd,
+ stts: writeStts,
+ trex: writeTrex,
+ // Data segment boxes
+ mfhd: writeMfhd,
+ tfhd: writeTfhd,
+ tfdt: writeTfdt,
+ trun: writeTrun,
+ mdat: writeMdat,
+ // For boxes without library writers, we create them manually as Uint8Arrays
+};
+
+/** Write boxes using our WRITERS config. */
+function writeBoxes(boxes: Iterable): Uint8Array[] {
+ return writeIsoBoxes(boxes, { writers: WRITERS });
+}
+
+/**
+ * Helper to create a simple full box (version + flags + content) as raw bytes.
+ * Used for boxes that don't have writers in the library.
+ */
+function createFullBox(type: string, version: number, flags: number, content: Uint8Array): Uint8Array {
+ const size = 8 + 4 + content.length; // header + version/flags + content
+ const box = new Uint8Array(size);
+ const view = new DataView(box.buffer);
+
+ view.setUint32(0, size, false);
+ box[4] = type.charCodeAt(0);
+ box[5] = type.charCodeAt(1);
+ box[6] = type.charCodeAt(2);
+ box[7] = type.charCodeAt(3);
+ view.setUint32(8, (version << 24) | flags, false);
+ box.set(content, 12);
+
+ return box;
+}
+
+/**
+ * Create an empty stsc (Sample to Chunk) box for fragmented MP4.
+ */
+function createEmptyStsc(): Uint8Array {
+ // stsc: version(1) + flags(3) + entry_count(4) = 4 bytes content
+ const content = new Uint8Array(4); // entry_count = 0
+ return createFullBox("stsc", 0, 0, content);
+}
+
+/**
+ * Create an empty stsz (Sample Size) box for fragmented MP4.
+ */
+function createEmptyStsz(): Uint8Array {
+ // stsz: version(1) + flags(3) + sample_size(4) + sample_count(4) = 8 bytes content
+ const content = new Uint8Array(8); // sample_size = 0, sample_count = 0
+ return createFullBox("stsz", 0, 0, content);
+}
+
+/**
+ * Create an empty stco (Chunk Offset) box for fragmented MP4.
+ */
+function createEmptyStco(): Uint8Array {
+ // stco: version(1) + flags(3) + entry_count(4) = 4 bytes content
+ const content = new Uint8Array(4); // entry_count = 0
+ return createFullBox("stco", 0, 0, content);
+}
+
+/**
+ * Create an avc1 (H.264 Visual Sample Entry) box with embedded avcC.
+ * Built manually because the library doesn't properly serialize Uint8Array child boxes.
+ */
+function createAvc1Box(width: number, height: number, avcC: Uint8Array): Uint8Array {
+ // avc1 box structure:
+ // - 6 bytes reserved (0)
+ // - 2 bytes data_reference_index (1)
+ // - 2 bytes pre_defined (0)
+ // - 2 bytes reserved (0)
+ // - 12 bytes pre_defined (0)
+ // - 2 bytes width
+ // - 2 bytes height
+ // - 4 bytes horizresolution (0x00480000 = 72 dpi)
+ // - 4 bytes vertresolution (0x00480000 = 72 dpi)
+ // - 4 bytes reserved (0)
+ // - 2 bytes frame_count (1)
+ // - 32 bytes compressorname (null-padded string)
+ // - 2 bytes depth (0x0018 = 24)
+ // - 2 bytes pre_defined (-1 = 0xFFFF)
+ // - child boxes (avcC)
+
+ const avcCSize = 8 + avcC.length; // box header + payload
+ const avc1ContentSize = 6 + 2 + 2 + 2 + 12 + 2 + 2 + 4 + 4 + 4 + 2 + 32 + 2 + 2 + avcCSize;
+ const avc1Size = 8 + avc1ContentSize; // box header + content
+
+ const box = new Uint8Array(avc1Size);
+ const view = new DataView(box.buffer);
+ let offset = 0;
+
+ // Box header
+ view.setUint32(offset, avc1Size, false);
+ offset += 4;
+ box[offset++] = 0x61; // 'a'
+ box[offset++] = 0x76; // 'v'
+ box[offset++] = 0x63; // 'c'
+ box[offset++] = 0x31; // '1'
+
+ // SampleEntry fields
+ offset += 6; // reserved (6 bytes of 0)
+ view.setUint16(offset, 1, false);
+ offset += 2; // data_reference_index = 1
+
+ // VisualSampleEntry fields
+ view.setUint16(offset, 0, false);
+ offset += 2; // pre_defined
+ view.setUint16(offset, 0, false);
+ offset += 2; // reserved
+ offset += 12; // pre_defined (12 bytes of 0)
+ view.setUint16(offset, width, false);
+ offset += 2;
+ view.setUint16(offset, height, false);
+ offset += 2;
+ view.setUint32(offset, 0x00480000, false);
+ offset += 4; // horizresolution (72 dpi)
+ view.setUint32(offset, 0x00480000, false);
+ offset += 4; // vertresolution (72 dpi)
+ view.setUint32(offset, 0, false);
+ offset += 4; // reserved
+ view.setUint16(offset, 1, false);
+ offset += 2; // frame_count = 1
+ offset += 32; // compressorname (32 bytes of 0)
+ view.setUint16(offset, 0x0018, false);
+ offset += 2; // depth = 24
+ view.setUint16(offset, 0xffff, false);
+ offset += 2; // pre_defined = -1
+
+ // avcC child box
+ view.setUint32(offset, avcCSize, false);
+ offset += 4;
+ box[offset++] = 0x61; // 'a'
+ box[offset++] = 0x76; // 'v'
+ box[offset++] = 0x63; // 'c'
+ box[offset++] = 0x43; // 'C'
+ box.set(avcC, offset);
+
+ return box;
+}
+
+/**
+ * Creates an MSE-compatible initialization segment (ftyp + moov) for H.264 video.
+ *
+ * @example
+ * ```ts
+ * // From WebCodecs EncodedVideoChunkMetadata
+ * const config = await encoder.encode(frame);
+ * const metadata = config.decoderConfig;
+ *
+ * const initSegment = createVideoInitSegment({
+ * width: metadata.codedWidth,
+ * height: metadata.codedHeight,
+ * avcC: new Uint8Array(metadata.description),
+ * });
+ *
+ * sourceBuffer.appendBuffer(initSegment);
+ * ```
+ */
+export function createVideoInitSegment(config: Catalog.VideoConfig): Uint8Array {
+ const { codedWidth, codedHeight, description } = config;
+ if (!codedWidth || !codedHeight || !description) {
+ throw new Error("Missing required fields to create video init segment");
+ }
+
+ // Legacy container always uses microsecond timescale and track ID 1.
+ // For CMAF, the init segment in the catalog is authoritative; this builder
+ // is only used for the legacy path.
+ const timescale = 1_000_000;
+ const trackId = 1;
+
+ // ftyp - File Type Box
+ const ftyp: FileTypeBox = {
+ type: "ftyp",
+ majorBrand: "isom",
+ minorVersion: 0x200,
+ compatibleBrands: ["isom", "iso6", "mp41"],
+ };
+
+ // mvhd - Movie Header Box
+ const mvhd: MovieHeaderBox = {
+ type: "mvhd",
+ version: 0,
+ flags: 0,
+ creationTime: 0,
+ modificationTime: 0,
+ timescale: timescale,
+ duration: 0, // Unknown/fragmented
+ rate: 0x00010000, // 1.0 in 16.16 fixed point
+ volume: 0x0100, // 1.0 in 8.8 fixed point
+ reserved1: 0,
+ reserved2: [0, 0],
+ matrix: IDENTITY_MATRIX,
+ preDefined: [0, 0, 0, 0, 0, 0],
+ nextTrackId: trackId + 1,
+ };
+
+ // tkhd - Track Header Box
+ const tkhd: TrackHeaderBox = {
+ type: "tkhd",
+ version: 0,
+ flags: 0x000003, // Track enabled + in movie
+ creationTime: 0,
+ modificationTime: 0,
+ trackId: trackId,
+ reserved1: 0,
+ duration: 0,
+ reserved2: [0, 0],
+ layer: 0,
+ alternateGroup: 0,
+ volume: 0, // Video tracks have 0 volume
+ reserved3: 0,
+ matrix: IDENTITY_MATRIX,
+ width: codedWidth * 0x10000, // 16.16 fixed point (avoid << which produces signed int)
+ height: codedHeight * 0x10000,
+ };
+
+ // mdhd - Media Header Box
+ const mdhd: MediaHeaderBox = {
+ type: "mdhd",
+ version: 0,
+ flags: 0,
+ creationTime: 0,
+ modificationTime: 0,
+ timescale: timescale,
+ duration: 0,
+ language: "und",
+ preDefined: 0,
+ };
+
+ // hdlr - Handler Reference Box
+ const hdlr: HandlerReferenceBox = {
+ type: "hdlr",
+ version: 0,
+ flags: 0,
+ preDefined: 0,
+ handlerType: "vide",
+ reserved: [0, 0, 0],
+ name: "VideoHandler",
+ };
+
+ // vmhd - Video Media Header Box
+ const vmhd: VideoMediaHeaderBox = {
+ type: "vmhd",
+ version: 0,
+ flags: 1, // Required to be 1
+ graphicsmode: 0,
+ opcolor: [0, 0, 0],
+ };
+
+ // url - Data Entry URL Box (self-contained)
+ const urlBox: DataEntryUrlBox = {
+ type: "url ",
+ version: 0,
+ flags: 0x000001, // Self-contained flag
+ location: "",
+ };
+
+ // dref - Data Reference Box
+ const dref: DataReferenceBox = {
+ type: "dref",
+ version: 0,
+ flags: 0,
+ entryCount: 1,
+ entries: [urlBox],
+ };
+
+ // dinf - Data Information Box
+ const dinf: DataInformationBox = {
+ type: "dinf",
+ boxes: [dref],
+ };
+
+ // Build the avc1 box manually since the library doesn't properly serialize Uint8Array children
+ const avc1Box = createAvc1Box(codedWidth, codedHeight, Hex.toBytes(description));
+
+ // stsd - Sample Description Box
+ const stsd: SampleDescriptionBox = {
+ type: "stsd",
+ version: 0,
+ flags: 0,
+ entryCount: 1,
+ // biome-ignore lint/suspicious/noExplicitAny: Raw avc1 box since library doesn't handle avcC children
+ entries: [avc1Box] as any[],
+ };
+
+ // stts - Decoding Time to Sample (empty for fragmented)
+ const stts: DecodingTimeToSampleBox = {
+ type: "stts",
+ version: 0,
+ flags: 0,
+ entryCount: 0,
+ entries: [],
+ };
+
+ // Create raw boxes for types without library writers
+ const stsc = createEmptyStsc();
+ const stsz = createEmptyStsz();
+ const stco = createEmptyStco();
+
+ // stbl - Sample Table Box
+ // Note: stsc, stsz, stco are raw Uint8Arrays since the library doesn't have writers for them
+ const stbl: SampleTableBox = {
+ type: "stbl",
+ // biome-ignore lint/suspicious/noExplicitAny: Raw boxes for types without library writers
+ boxes: [stsd, stts, stsc, stsz, stco] as any[],
+ };
+
+ // minf - Media Information Box
+ const minf: MediaInformationBox = {
+ type: "minf",
+ boxes: [vmhd, dinf, stbl],
+ };
+
+ // mdia - Media Box
+ const mdia: MediaBox = {
+ type: "mdia",
+ boxes: [mdhd, hdlr, minf],
+ };
+
+ // trak - Track Box
+ const trak: TrackBox = {
+ type: "trak",
+ boxes: [tkhd, mdia],
+ };
+
+ // trex - Track Extends Box (required for fragmented MP4)
+ const trex: TrackExtendsBox = {
+ type: "trex",
+ version: 0,
+ flags: 0,
+ trackId: trackId,
+ defaultSampleDescriptionIndex: 1,
+ defaultSampleDuration: 0,
+ defaultSampleSize: 0,
+ defaultSampleFlags: 0,
+ };
+
+ // mvex - Movie Extends Box (signals fragmented MP4)
+ const mvex: MovieExtendsBox = {
+ type: "mvex",
+ boxes: [trex],
+ };
+
+ // moov - Movie Box
+ const moov: MovieBox = {
+ type: "moov",
+ boxes: [mvhd, trak, mvex],
+ };
+
+ // Write all boxes and concatenate
+ const buffers = writeBoxes([ftyp, moov]);
+ const totalLength = buffers.reduce((sum, buf) => sum + buf.byteLength, 0);
+ const result = new Uint8Array(totalLength);
+
+ let offset = 0;
+ for (const buf of buffers) {
+ result.set(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength), offset);
+ offset += buf.byteLength;
+ }
+
+ return result;
+}
+
+/**
+ * Creates an MSE-compatible initialization segment (ftyp + moov) for audio.
+ * Supports AAC (mp4a) and Opus codecs.
+ */
+export function createAudioInitSegment(config: Catalog.AudioConfig): Uint8Array {
+ const { sampleRate, numberOfChannels, description, codec } = config;
+
+ // Legacy container always uses microsecond timescale and track ID 1.
+ const timescale = 1_000_000;
+ const trackId = 1;
+
+ // ftyp - File Type Box
+ const ftyp: FileTypeBox = {
+ type: "ftyp",
+ majorBrand: "isom",
+ minorVersion: 0x200,
+ compatibleBrands: ["isom", "iso6", "mp41"],
+ };
+
+ // mvhd - Movie Header Box
+ const mvhd: MovieHeaderBox = {
+ type: "mvhd",
+ version: 0,
+ flags: 0,
+ creationTime: 0,
+ modificationTime: 0,
+ timescale: timescale,
+ duration: 0,
+ rate: 0x00010000,
+ volume: 0x0100,
+ reserved1: 0,
+ reserved2: [0, 0],
+ matrix: IDENTITY_MATRIX,
+ preDefined: [0, 0, 0, 0, 0, 0],
+ nextTrackId: trackId + 1,
+ };
+
+ // tkhd - Track Header Box
+ const tkhd: TrackHeaderBox = {
+ type: "tkhd",
+ version: 0,
+ flags: 0x000003,
+ creationTime: 0,
+ modificationTime: 0,
+ trackId: trackId,
+ reserved1: 0,
+ duration: 0,
+ reserved2: [0, 0],
+ layer: 0,
+ alternateGroup: 0,
+ volume: 0x0100, // Audio tracks have volume (1.0 in 8.8 fixed point)
+ reserved3: 0,
+ matrix: IDENTITY_MATRIX,
+ width: 0,
+ height: 0,
+ };
+
+ // mdhd - Media Header Box
+ const mdhd: MediaHeaderBox = {
+ type: "mdhd",
+ version: 0,
+ flags: 0,
+ creationTime: 0,
+ modificationTime: 0,
+ timescale: timescale,
+ duration: 0,
+ language: "und",
+ preDefined: 0,
+ };
+
+ // hdlr - Handler Reference Box
+ const hdlr: HandlerReferenceBox = {
+ type: "hdlr",
+ version: 0,
+ flags: 0,
+ preDefined: 0,
+ handlerType: "soun",
+ reserved: [0, 0, 0],
+ name: "SoundHandler",
+ };
+
+ // smhd - Sound Media Header Box
+ const smhd: SoundMediaHeaderBox = {
+ type: "smhd",
+ version: 0,
+ flags: 0,
+ balance: 0,
+ reserved: 0,
+ };
+
+ // url - Data Entry URL Box (self-contained)
+ const urlBox: DataEntryUrlBox = {
+ type: "url ",
+ version: 0,
+ flags: 0x000001,
+ location: "",
+ };
+
+ // dref - Data Reference Box
+ const dref: DataReferenceBox = {
+ type: "dref",
+ version: 0,
+ flags: 0,
+ entryCount: 1,
+ entries: [urlBox],
+ };
+
+ // dinf - Data Information Box
+ const dinf: DataInformationBox = {
+ type: "dinf",
+ boxes: [dref],
+ };
+
+ // Build codec-specific sample entry (manually to ensure child boxes are properly serialized)
+ const sampleEntry = createAudioSampleEntry(codec, sampleRate, numberOfChannels, description);
+
+ // stsd - Sample Description Box
+ const stsd: SampleDescriptionBox = {
+ type: "stsd",
+ version: 0,
+ flags: 0,
+ entryCount: 1,
+ // biome-ignore lint/suspicious/noExplicitAny: Raw sample entry box
+ entries: [sampleEntry] as any[],
+ };
+
+ // stts - Decoding Time to Sample (empty for fragmented)
+ const stts: DecodingTimeToSampleBox = {
+ type: "stts",
+ version: 0,
+ flags: 0,
+ entryCount: 0,
+ entries: [],
+ };
+
+ // Create raw boxes for types without library writers
+ const stsc = createEmptyStsc();
+ const stsz = createEmptyStsz();
+ const stco = createEmptyStco();
+
+ // stbl - Sample Table Box
+ // Note: stsc, stsz, stco are raw Uint8Arrays since the library doesn't have writers for them
+ const stbl: SampleTableBox = {
+ type: "stbl",
+ // biome-ignore lint/suspicious/noExplicitAny: Raw boxes for types without library writers
+ boxes: [stsd, stts, stsc, stsz, stco] as any[],
+ };
+
+ // minf - Media Information Box
+ const minf: MediaInformationBox = {
+ type: "minf",
+ boxes: [smhd, dinf, stbl],
+ };
+
+ // mdia - Media Box
+ const mdia: MediaBox = {
+ type: "mdia",
+ boxes: [mdhd, hdlr, minf],
+ };
+
+ // trak - Track Box
+ const trak: TrackBox = {
+ type: "trak",
+ boxes: [tkhd, mdia],
+ };
+
+ // trex - Track Extends Box
+ const trex: TrackExtendsBox = {
+ type: "trex",
+ version: 0,
+ flags: 0,
+ trackId: trackId,
+ defaultSampleDescriptionIndex: 1,
+ defaultSampleDuration: 0,
+ defaultSampleSize: 0,
+ defaultSampleFlags: 0,
+ };
+
+ // mvex - Movie Extends Box
+ const mvex: MovieExtendsBox = {
+ type: "mvex",
+ boxes: [trex],
+ };
+
+ // moov - Movie Box
+ const moov: MovieBox = {
+ type: "moov",
+ boxes: [mvhd, trak, mvex],
+ };
+
+ // Write all boxes and concatenate
+ const buffers = writeBoxes([ftyp, moov]);
+ const totalLength = buffers.reduce((sum, buf) => sum + buf.byteLength, 0);
+ const result = new Uint8Array(totalLength);
+
+ let offset = 0;
+ for (const buf of buffers) {
+ result.set(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength), offset);
+ offset += buf.byteLength;
+ }
+
+ return result;
+}
+
+/**
+ * Create an audio sample entry box (mp4a or Opus) with embedded codec config.
+ * Built manually because the library doesn't properly serialize Uint8Array child boxes.
+ */
+function createAudioSampleEntry(
+ codec: string,
+ sampleRate: number,
+ channelCount: number,
+ description?: string,
+): Uint8Array {
+ if (codec.startsWith("mp4a")) {
+ return createMp4aBox(sampleRate, channelCount, description);
+ } else if (codec === "opus") {
+ return createOpusBox(sampleRate, channelCount, description);
+ }
+ throw new Error(`Unsupported audio codec: ${codec}`);
+}
+
+/**
+ * Create an mp4a (AAC Audio Sample Entry) box with embedded esds.
+ */
+function createMp4aBox(sampleRate: number, channelCount: number, description?: string): Uint8Array {
+ const esds = createEsdsBox(sampleRate, channelCount, description);
+
+ // mp4a box structure (AudioSampleEntry):
+ // - 6 bytes reserved (0)
+ // - 2 bytes data_reference_index (1)
+ // - 8 bytes reserved (0) - includes reserved2[2] and pre_defined fields
+ // - 2 bytes channelcount
+ // - 2 bytes samplesize (16)
+ // - 2 bytes pre_defined (0)
+ // - 2 bytes reserved (0)
+ // - 4 bytes samplerate (16.16 fixed point)
+ // - child boxes (esds)
+
+ const mp4aContentSize = 6 + 2 + 8 + 2 + 2 + 2 + 2 + 4 + esds.length;
+ const mp4aSize = 8 + mp4aContentSize;
+
+ const box = new Uint8Array(mp4aSize);
+ const view = new DataView(box.buffer);
+ let offset = 0;
+
+ // Box header
+ view.setUint32(offset, mp4aSize, false);
+ offset += 4;
+ box[offset++] = 0x6d; // 'm'
+ box[offset++] = 0x70; // 'p'
+ box[offset++] = 0x34; // '4'
+ box[offset++] = 0x61; // 'a'
+
+ // SampleEntry fields
+ offset += 6; // reserved (6 bytes of 0)
+ view.setUint16(offset, 1, false);
+ offset += 2; // data_reference_index = 1
+
+ // AudioSampleEntry fields
+ offset += 8; // reserved (8 bytes of 0)
+ view.setUint16(offset, channelCount, false);
+ offset += 2;
+ view.setUint16(offset, 16, false);
+ offset += 2; // samplesize = 16
+ view.setUint16(offset, 0, false);
+ offset += 2; // pre_defined
+ view.setUint16(offset, 0, false);
+ offset += 2; // reserved
+ view.setUint32(offset, sampleRate * 0x10000, false);
+ offset += 4; // samplerate (16.16 fixed point)
+
+ // esds child box (already includes box header)
+ box.set(esds, offset);
+
+ return box;
+}
+
+/**
+ * Create an Opus (Opus Audio Sample Entry) box with embedded dOps.
+ */
+function createOpusBox(sampleRate: number, channelCount: number, description?: string): Uint8Array {
+ const dOps = createDOpsBox(channelCount, sampleRate, description);
+
+ // Opus box structure (AudioSampleEntry):
+ // Same structure as mp4a
+ const opusContentSize = 6 + 2 + 8 + 2 + 2 + 2 + 2 + 4 + dOps.length;
+ const opusSize = 8 + opusContentSize;
+
+ const box = new Uint8Array(opusSize);
+ const view = new DataView(box.buffer);
+ let offset = 0;
+
+ // Box header
+ view.setUint32(offset, opusSize, false);
+ offset += 4;
+ box[offset++] = 0x4f; // 'O'
+ box[offset++] = 0x70; // 'p'
+ box[offset++] = 0x75; // 'u'
+ box[offset++] = 0x73; // 's'
+
+ // SampleEntry fields
+ offset += 6; // reserved (6 bytes of 0)
+ view.setUint16(offset, 1, false);
+ offset += 2; // data_reference_index = 1
+
+ // AudioSampleEntry fields
+ offset += 8; // reserved (8 bytes of 0)
+ view.setUint16(offset, channelCount, false);
+ offset += 2;
+ view.setUint16(offset, 16, false);
+ offset += 2; // samplesize = 16
+ view.setUint16(offset, 0, false);
+ offset += 2; // pre_defined
+ view.setUint16(offset, 0, false);
+ offset += 2; // reserved
+ view.setUint32(offset, sampleRate * 0x10000, false);
+ offset += 4; // samplerate (16.16 fixed point)
+
+ // dOps child box (already includes box header)
+ box.set(dOps, offset);
+
+ return box;
+}
+
+/**
+ * Generate an AudioSpecificConfig for AAC-LC.
+ * Format: 5 bits audioObjectType + 4 bits samplingFrequencyIndex + 4 bits channelConfiguration + 3 bits (zeros)
+ */
+function generateAudioSpecificConfig(sampleRate: number, channelCount: number): Uint8Array {
+ // Sampling frequency index lookup
+ const sampleRateIndex: Record = {
+ 96000: 0,
+ 88200: 1,
+ 64000: 2,
+ 48000: 3,
+ 44100: 4,
+ 32000: 5,
+ 24000: 6,
+ 22050: 7,
+ 16000: 8,
+ 12000: 9,
+ 11025: 10,
+ 8000: 11,
+ 7350: 12,
+ };
+
+ const freqIndex = sampleRateIndex[sampleRate] ?? 4; // Default to 44100 if not found
+ const audioObjectType = 2; // AAC-LC
+
+ // AudioSpecificConfig is 2 bytes for AAC-LC:
+ // 5 bits: audioObjectType (2 for AAC-LC)
+ // 4 bits: samplingFrequencyIndex
+ // 4 bits: channelConfiguration
+ // 3 bits: GASpecificConfig (frameLengthFlag=0, dependsOnCoreCoder=0, extensionFlag=0)
+ const byte0 = (audioObjectType << 3) | (freqIndex >> 1);
+ const byte1 = ((freqIndex & 1) << 7) | (channelCount << 3);
+
+ return new Uint8Array([byte0, byte1]);
+}
+
+/**
+ * Creates an esds (Elementary Stream Descriptor) box for AAC.
+ * The description from WebCodecs is the AudioSpecificConfig.
+ * If no description is provided, generates one from sampleRate and channelCount.
+ */
+function createEsdsBox(sampleRate: number, channelCount: number, description?: string): Uint8Array {
+ const audioSpecificConfig = description
+ ? Hex.toBytes(description)
+ : generateAudioSpecificConfig(sampleRate, channelCount);
+
+ // ES_Descriptor structure:
+ // - tag (0x03) + size + ES_ID (2) + flags (1)
+ // - DecoderConfigDescriptor: tag (0x04) + size + objectTypeIndication (1) + streamType (1) + bufferSizeDB (3) + maxBitrate (4) + avgBitrate (4)
+ // - DecoderSpecificInfo: tag (0x05) + size + AudioSpecificConfig
+ // - SLConfigDescriptor: tag (0x06) + size + predefined (1)
+
+ const decSpecificInfoSize = audioSpecificConfig.length;
+ const decConfigDescSize = 13 + 2 + decSpecificInfoSize; // 13 fixed + tag/size + ASC
+ const esDescSize = 3 + 2 + decConfigDescSize + 3; // 3 fixed + tag/size + DCD + SLC (3 bytes)
+
+ const esdsSize = 12 + 2 + esDescSize; // 4 (size) + 4 (type) + 4 (version/flags) + tag/size + ESD
+ const esds = new Uint8Array(esdsSize);
+ const view = new DataView(esds.buffer);
+
+ let offset = 0;
+
+ // Box header
+ view.setUint32(offset, esdsSize, false);
+ offset += 4;
+ esds[offset++] = 0x65; // 'e'
+ esds[offset++] = 0x73; // 's'
+ esds[offset++] = 0x64; // 'd'
+ esds[offset++] = 0x73; // 's'
+
+ // Version and flags (full box)
+ view.setUint32(offset, 0, false);
+ offset += 4;
+
+ // ES_Descriptor
+ esds[offset++] = 0x03; // tag
+ esds[offset++] = esDescSize; // size (assuming < 128)
+
+ view.setUint16(offset, 0, false);
+ offset += 2; // ES_ID
+ esds[offset++] = 0; // flags
+
+ // DecoderConfigDescriptor
+ esds[offset++] = 0x04; // tag
+ esds[offset++] = decConfigDescSize; // size
+
+ esds[offset++] = 0x40; // objectTypeIndication: Audio ISO/IEC 14496-3 (AAC)
+ esds[offset++] = 0x15; // streamType (5 = audio) << 2 | upstream (0) << 1 | reserved (1)
+ esds[offset++] = 0x00; // bufferSizeDB (3 bytes)
+ esds[offset++] = 0x00;
+ esds[offset++] = 0x00;
+ view.setUint32(offset, 0, false);
+ offset += 4; // maxBitrate
+ view.setUint32(offset, 0, false);
+ offset += 4; // avgBitrate
+
+ // DecoderSpecificInfo (AudioSpecificConfig)
+ esds[offset++] = 0x05; // tag
+ esds[offset++] = decSpecificInfoSize; // size
+ esds.set(audioSpecificConfig, offset);
+ offset += decSpecificInfoSize;
+
+ // SLConfigDescriptor
+ esds[offset++] = 0x06; // tag
+ esds[offset++] = 0x01; // size
+ esds[offset++] = 0x02; // predefined = MP4
+
+ return esds;
+}
+
+/**
+ * Creates a dOps (Opus Specific) box.
+ * See https://opus-codec.org/docs/opus_in_isobmff.html
+ */
+function createDOpsBox(channelCount: number, sampleRate: number, description?: string): Uint8Array {
+ // If description is provided, it's the OpusHead without the magic signature
+ if (description) {
+ const opusHead = Hex.toBytes(description);
+ const dOpsSize = 8 + opusHead.length;
+ const dOps = new Uint8Array(dOpsSize);
+ const view = new DataView(dOps.buffer);
+
+ view.setUint32(0, dOpsSize, false);
+ dOps[4] = 0x64; // 'd'
+ dOps[5] = 0x4f; // 'O'
+ dOps[6] = 0x70; // 'p'
+ dOps[7] = 0x73; // 's'
+ dOps.set(opusHead, 8);
+
+ return dOps;
+ }
+
+ // Build minimal dOps box
+ // dOps structure: Version (1) + OutputChannelCount (1) + PreSkip (2) +
+ // InputSampleRate (4) + OutputGain (2) + ChannelMappingFamily (1)
+ const dOpsSize = 8 + 11; // box header + content
+ const dOps = new Uint8Array(dOpsSize);
+ const view = new DataView(dOps.buffer);
+
+ let offset = 0;
+ view.setUint32(offset, dOpsSize, false);
+ offset += 4;
+ dOps[offset++] = 0x64; // 'd'
+ dOps[offset++] = 0x4f; // 'O'
+ dOps[offset++] = 0x70; // 'p'
+ dOps[offset++] = 0x73; // 's'
+
+ dOps[offset++] = 0; // Version
+ dOps[offset++] = channelCount;
+ view.setUint16(offset, 312, false);
+ offset += 2; // PreSkip (typical value)
+ view.setUint32(offset, sampleRate, false);
+ offset += 4; // InputSampleRate
+ view.setInt16(offset, 0, false);
+ offset += 2; // OutputGain
+ dOps[offset++] = 0; // ChannelMappingFamily (0 = mono/stereo)
+
+ return dOps;
+}
+
+export interface DataSegmentOptions {
+ /** Raw frame data */
+ data: Uint8Array;
+ /** Timestamp in timescale units */
+ timestamp: number;
+ /** Duration in timescale units */
+ duration: number;
+ /** Whether this is a keyframe */
+ keyframe: boolean;
+ /** Sequence number for this fragment */
+ sequence: number;
+ /** Track ID (default: 1) */
+ trackId?: number;
+}
+
+/**
+ * Encode a raw frame into a moof+mdat segment for MSE.
+ *
+ * @param opts - Options for the data segment
+ * @returns The encoded moof+mdat segment
+ */
+export function encodeDataSegment(opts: DataSegmentOptions): Uint8Array {
+ const { data, timestamp, duration, keyframe, sequence, trackId = 1 } = opts;
+
+ // Sample flags:
+ // - sample_depends_on: bits 25-24 (2 = does not depend on others for IDR, 1 = depends on others)
+ // - sample_is_non_sync_sample: bit 16 (0 = sync/keyframe, 1 = non-sync)
+ // For keyframe: depends_on=2 (0x02000000), non_sync=0
+ // For non-keyframe: depends_on=1 (0x01000000), non_sync=1 (0x00010000)
+ const sampleFlags = keyframe ? 0x02000000 : 0x01010000;
+
+ // mfhd - Movie Fragment Header
+ const mfhd: MovieFragmentHeaderBox = {
+ type: "mfhd",
+ version: 0,
+ flags: 0,
+ sequenceNumber: sequence,
+ };
+
+ // tfhd - Track Fragment Header
+ // Flags: default-base-is-moof (0x020000)
+ const tfhd: TrackFragmentHeaderBox = {
+ type: "tfhd",
+ version: 0,
+ flags: 0x020000,
+ trackId,
+ };
+
+ // tfdt - Track Fragment Base Media Decode Time
+ const tfdt: TrackFragmentBaseMediaDecodeTimeBox = {
+ type: "tfdt",
+ version: 1, // version 1 for 64-bit baseMediaDecodeTime
+ flags: 0,
+ baseMediaDecodeTime: timestamp,
+ };
+
+ // trun - Track Run
+ // Flags: data-offset-present (0x000001) | sample-duration-present (0x000100) |
+ // sample-size-present (0x000200) | sample-flags-present (0x000400)
+ const trun: TrackRunBox = {
+ type: "trun",
+ version: 0,
+ flags: 0x000001 | 0x000100 | 0x000200 | 0x000400,
+ sampleCount: 1,
+ dataOffset: 0, // Will be calculated after we know moof size
+ samples: [
+ {
+ sampleDuration: duration,
+ sampleSize: data.byteLength,
+ sampleFlags,
+ },
+ ],
+ };
+
+ // traf - Track Fragment
+ const traf: TrackFragmentBox = {
+ type: "traf",
+ boxes: [tfhd, tfdt, trun],
+ };
+
+ // moof - Movie Fragment
+ const moof: MovieFragmentBox = {
+ type: "moof",
+ boxes: [mfhd, traf],
+ };
+
+ // Write moof to calculate its size
+ const moofBuffers = writeBoxes([moof]);
+ let moofSize = 0;
+ for (const buf of moofBuffers) {
+ moofSize += buf.byteLength;
+ }
+
+ // Update trun.dataOffset to point to mdat data
+ // dataOffset is relative to moof start, so it's moofSize + 8 (mdat header)
+ trun.dataOffset = moofSize + 8;
+
+ // Re-write moof with correct dataOffset
+ const moofBuffersFinal = writeBoxes([moof]);
+ moofSize = 0;
+ for (const buf of moofBuffersFinal) {
+ moofSize += buf.byteLength;
+ }
+
+ // mdat - Media Data
+ // Need to ensure the data is a proper ArrayBuffer-backed Uint8Array for the library
+ const mdatBuffer = new ArrayBuffer(data.byteLength);
+ const mdatData = new Uint8Array(mdatBuffer);
+ mdatData.set(data);
+ const mdat: MediaDataBox = {
+ type: "mdat",
+ data: mdatData,
+ };
+
+ const mdatBuffers = writeBoxes([mdat]);
+ let mdatSize = 0;
+ for (const buf of mdatBuffers) {
+ mdatSize += buf.byteLength;
+ }
+
+ // Concatenate all buffers
+ const result = new Uint8Array(moofSize + mdatSize);
+ let offset = 0;
+
+ for (const buf of moofBuffersFinal) {
+ result.set(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength), offset);
+ offset += buf.byteLength;
+ }
+
+ for (const buf of mdatBuffers) {
+ result.set(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength), offset);
+ offset += buf.byteLength;
+ }
+
+ return result;
+}
diff --git a/js/hang/src/container/cmaf/format.ts b/js/hang/src/container/cmaf/format.ts
new file mode 100644
index 0000000000..d7ae82ba72
--- /dev/null
+++ b/js/hang/src/container/cmaf/format.ts
@@ -0,0 +1,20 @@
+import type { Time } from "@moq/net";
+import type { Format as ContainerFormat } from "../format";
+import type { Frame } from "../types";
+import { decodeDataSegment, type InitSegment } from "./decode";
+
+export class Format implements ContainerFormat {
+ #init: InitSegment;
+
+ constructor(init: InitSegment) {
+ this.#init = init;
+ }
+
+ decode(frame: Uint8Array): Frame[] {
+ return decodeDataSegment(frame, this.#init).map((s) => ({
+ data: s.data,
+ timestamp: s.timestamp as Time.Micro,
+ keyframe: s.keyframe,
+ }));
+ }
+}
diff --git a/js/hang/src/container/cmaf/index.ts b/js/hang/src/container/cmaf/index.ts
new file mode 100644
index 0000000000..265330c382
--- /dev/null
+++ b/js/hang/src/container/cmaf/index.ts
@@ -0,0 +1,7 @@
+/**
+ * CMAF (fMP4) container utilities for encoding and decoding segments.
+ */
+
+export * from "./decode";
+export * from "./encode";
+export { Format } from "./format";
diff --git a/js/hang/src/container/codec.ts b/js/hang/src/container/codec.ts
deleted file mode 100644
index ca60754a41..0000000000
--- a/js/hang/src/container/codec.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import type { Time } from "@moq/lite";
-import type * as Catalog from "../catalog";
-import { DEFAULT_CONTAINER } from "../catalog";
-
-/**
- * Encodes a timestamp according to the specified container format.
- *
- * @param timestamp - The timestamp in microseconds
- * @param container - The container format to use
- * @returns The encoded timestamp as a Uint8Array
- */
-export function encodeTimestamp(timestamp: Time.Micro, container: Catalog.Container = DEFAULT_CONTAINER): Uint8Array {
- switch (container) {
- case "legacy":
- return encodeVarInt(timestamp);
- case "raw":
- return encodeU64(timestamp);
- case "fmp4":
- throw new Error("fmp4 container not yet implemented");
- }
-}
-
-/**
- * Decodes a timestamp from a buffer according to the specified container format.
- *
- * @param buffer - The buffer containing the encoded timestamp
- * @param container - The container format to use
- * @returns [timestamp in microseconds, remaining buffer after timestamp]
- */
-export function decodeTimestamp(
- buffer: Uint8Array,
- container: Catalog.Container = DEFAULT_CONTAINER,
-): [Time.Micro, Uint8Array] {
- switch (container) {
- case "legacy": {
- const [value, remaining] = decodeVarInt(buffer);
- return [value as Time.Micro, remaining];
- }
- case "raw": {
- const [value, remaining] = decodeU64(buffer);
- return [value as Time.Micro, remaining];
- }
- case "fmp4":
- throw new Error("fmp4 container not yet implemented");
- }
-}
-
-/**
- * Gets the size in bytes of an encoded timestamp for the given container format.
- * For variable-length formats, returns the maximum size.
- *
- * @param container - The container format
- * @returns Size in bytes
- */
-export function getTimestampSize(container: Catalog.Container = DEFAULT_CONTAINER): number {
- switch (container) {
- case "legacy":
- return 8; // VarInt maximum size
- case "raw":
- return 8; // u64 fixed size
- case "fmp4":
- throw new Error("fmp4 container not yet implemented");
- }
-}
-
-// ============================================================================
-// LEGACY VARINT IMPLEMENTATION
-// ============================================================================
-
-const MAX_U6 = 2 ** 6 - 1;
-const MAX_U14 = 2 ** 14 - 1;
-const MAX_U30 = 2 ** 30 - 1;
-const MAX_U53 = Number.MAX_SAFE_INTEGER;
-
-function decodeVarInt(buf: Uint8Array): [number, Uint8Array] {
- const size = 1 << ((buf[0] & 0xc0) >> 6);
-
- const view = new DataView(buf.buffer, buf.byteOffset, size);
- const remain = new Uint8Array(buf.buffer, buf.byteOffset + size, buf.byteLength - size);
- let v: number;
-
- if (size === 1) {
- v = buf[0] & 0x3f;
- } else if (size === 2) {
- v = view.getUint16(0) & 0x3fff;
- } else if (size === 4) {
- v = view.getUint32(0) & 0x3fffffff;
- } else if (size === 8) {
- // NOTE: Precision loss above 2^52
- v = Number(view.getBigUint64(0) & 0x3fffffffffffffffn);
- } else {
- throw new Error("impossible");
- }
-
- return [v, remain];
-}
-
-function encodeVarInt(v: number): Uint8Array {
- const dst = new Uint8Array(8);
-
- if (v <= MAX_U6) {
- dst[0] = v;
- return new Uint8Array(dst.buffer, dst.byteOffset, 1);
- }
-
- if (v <= MAX_U14) {
- const view = new DataView(dst.buffer, dst.byteOffset, 2);
- view.setUint16(0, v | 0x4000);
- return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
- }
-
- if (v <= MAX_U30) {
- const view = new DataView(dst.buffer, dst.byteOffset, 4);
- view.setUint32(0, v | 0x80000000);
- return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
- }
-
- if (v <= MAX_U53) {
- const view = new DataView(dst.buffer, dst.byteOffset, 8);
- view.setBigUint64(0, BigInt(v) | 0xc000000000000000n);
- return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
- }
-
- throw new Error(`overflow, value larger than 53-bits: ${v}`);
-}
-
-// ============================================================================
-// RAW U64 IMPLEMENTATION
-// ============================================================================
-
-/**
- * Decodes a fixed 8-byte big-endian unsigned 64-bit integer.
- */
-function decodeU64(buf: Uint8Array): [number, Uint8Array] {
- if (buf.byteLength < 8) {
- throw new Error("Buffer too short for u64 decode");
- }
-
- const view = new DataView(buf.buffer, buf.byteOffset, 8);
- const value = Number(view.getBigUint64(0));
- const remain = new Uint8Array(buf.buffer, buf.byteOffset + 8, buf.byteLength - 8);
-
- return [value, remain];
-}
-
-/**
- * Encodes a number as a fixed 8-byte big-endian unsigned 64-bit integer.
- * Much simpler than VarInt!
- */
-function encodeU64(v: number): Uint8Array {
- const dst = new Uint8Array(8);
- const view = new DataView(dst.buffer, dst.byteOffset, 8);
- view.setBigUint64(0, BigInt(v));
- return dst;
-}
diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts
new file mode 100644
index 0000000000..7a96cc58be
--- /dev/null
+++ b/js/hang/src/container/consumer.test.ts
@@ -0,0 +1,488 @@
+import { expect, test } from "bun:test";
+import { Group, type Time, Track, Varint } from "@moq/net";
+import type { InitSegment } from "./cmaf/decode.ts";
+import { encodeDataSegment } from "./cmaf/encode.ts";
+import { Format as CmafFormat } from "./cmaf/format.ts";
+import { Consumer } from "./consumer.ts";
+import type { Format as ContainerFormat } from "./format.ts";
+import { Format as LegacyFormat } from "./legacy.ts";
+import type { Frame } from "./types.ts";
+
+const TIMESCALE = 90_000;
+const TEST_INIT: InitSegment = {
+ timescale: TIMESCALE,
+ trackId: 1,
+ defaultSampleDuration: 0,
+ defaultSampleSize: 0,
+ defaultSampleFlags: 0,
+};
+
+function encodeLegacyFrame(timestamp: Time.Micro, payload: Uint8Array): Uint8Array {
+ const tsBytes = Varint.encode(timestamp);
+ const data = new Uint8Array(tsBytes.byteLength + payload.byteLength);
+ data.set(tsBytes, 0);
+ data.set(payload, tsBytes.byteLength);
+ return data;
+}
+
+// --- LegacyFormat ---
+
+test("LegacyFormat decodes a valid frame", () => {
+ const format = new LegacyFormat();
+ const payload = new Uint8Array([0xde, 0xad]);
+ const timestamp = 1000 as Time.Micro;
+ const frame = encodeLegacyFrame(timestamp, payload);
+
+ const result = format.decode(frame);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].timestamp).toBe(timestamp);
+ expect(result[0].data).toEqual(payload);
+ expect(result[0].keyframe).toBe(false);
+});
+
+test("LegacyFormat always returns keyframe: false", () => {
+ const format = new LegacyFormat();
+ const frame = encodeLegacyFrame(0 as Time.Micro, new Uint8Array([0x01]));
+
+ const [decoded] = format.decode(frame);
+ expect(decoded.keyframe).toBe(false);
+});
+
+test("LegacyFormat always returns exactly one frame", () => {
+ const format = new LegacyFormat();
+ const frame = encodeLegacyFrame(5000 as Time.Micro, new Uint8Array([0x01, 0x02, 0x03]));
+
+ const result = format.decode(frame);
+ expect(result).toHaveLength(1);
+});
+
+test("LegacyFormat throws on empty input", () => {
+ const format = new LegacyFormat();
+ expect(() => format.decode(new Uint8Array(0))).toThrow();
+});
+
+test("LegacyFormat throws on truncated input", () => {
+ const format = new LegacyFormat();
+ // A varint that indicates more bytes follow but is truncated
+ expect(() => format.decode(new Uint8Array([0x80]))).toThrow();
+});
+
+// --- CmafFormat ---
+
+test("CmafFormat decodes a valid keyframe segment", () => {
+ const format = new CmafFormat(TEST_INIT);
+ const segment = encodeDataSegment({
+ data: new Uint8Array([0xca, 0xfe]),
+ timestamp: 0,
+ duration: 3000,
+ keyframe: true,
+ sequence: 0,
+ });
+
+ const result = format.decode(segment);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].data).toEqual(new Uint8Array([0xca, 0xfe]));
+ expect(result[0].timestamp).toBe(0 as Time.Micro);
+ expect(result[0].keyframe).toBe(true);
+});
+
+test("CmafFormat decodes a delta frame segment", () => {
+ const format = new CmafFormat(TEST_INIT);
+ const segment = encodeDataSegment({
+ data: new Uint8Array([0xbe, 0xef]),
+ timestamp: 3000,
+ duration: 3000,
+ keyframe: false,
+ sequence: 1,
+ });
+
+ const result = format.decode(segment);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].keyframe).toBe(false);
+});
+
+test("CmafFormat converts timescale units to microseconds", () => {
+ const format = new CmafFormat(TEST_INIT);
+ // 90000 timescale units = 1 second = 1_000_000 microseconds
+ const segment = encodeDataSegment({
+ data: new Uint8Array([0x01]),
+ timestamp: TIMESCALE,
+ duration: 3000,
+ keyframe: true,
+ sequence: 0,
+ });
+
+ const result = format.decode(segment);
+ expect(result[0].timestamp).toBe(1_000_000 as Time.Micro);
+});
+
+test("CmafFormat throws on corrupt segment", () => {
+ const format = new CmafFormat(TEST_INIT);
+ expect(() => format.decode(new Uint8Array([0x00, 0x01, 0x02]))).toThrow();
+});
+
+// --- Consumer ---
+
+function encodeLegacy(timestamp: Time.Micro): Uint8Array {
+ const tsBytes = Varint.encode(timestamp);
+ const payload = new Uint8Array([0xde, 0xad]);
+ const data = new Uint8Array(tsBytes.byteLength + payload.byteLength);
+ data.set(tsBytes, 0);
+ data.set(payload, tsBytes.byteLength);
+ return data;
+}
+
+function writeGroupWithLegacyFrames(track: Track, sequence: number, timestamps: Time.Micro[]) {
+ const group = new Group(sequence);
+ for (const ts of timestamps) {
+ group.writeFrame(encodeLegacy(ts));
+ }
+ group.close();
+ track.writeGroup(group);
+}
+
+async function drainFrames(
+ consumer: Consumer,
+ timeout: number,
+): Promise<{ timestamp: Time.Micro; group: number; keyframe: boolean }[]> {
+ const frames: { timestamp: Time.Micro; group: number; keyframe: boolean }[] = [];
+ for (;;) {
+ const result = await Promise.race([
+ consumer.next(),
+ new Promise((resolve) => setTimeout(() => resolve(null), timeout)),
+ ]);
+ if (result === null || result === undefined) break;
+ if (result.frame) {
+ frames.push({ timestamp: result.frame.timestamp, group: result.group, keyframe: result.frame.keyframe });
+ }
+ }
+ return frames;
+}
+
+test("Consumer delivers frames from a single group", async () => {
+ const track = new Track("test");
+ const consumer = new Consumer(track, { format: new LegacyFormat(), latency: 500 as Time.Milli });
+
+ writeGroupWithLegacyFrames(track, 0, [0 as Time.Micro, 33_000 as Time.Micro]);
+ track.close();
+
+ const frames = await drainFrames(consumer, 200);
+ expect(frames).toHaveLength(2);
+ expect(frames[0].timestamp).toBe(0 as Time.Micro);
+ expect(frames[1].timestamp).toBe(33_000 as Time.Micro);
+ consumer.close();
+});
+
+test("Consumer forces keyframe true at index 0", async () => {
+ const track = new Track("test");
+ const consumer = new Consumer(track, { format: new LegacyFormat(), latency: 500 as Time.Milli });
+
+ writeGroupWithLegacyFrames(track, 0, [0 as Time.Micro, 33_000 as Time.Micro]);
+ track.close();
+
+ const frames = await drainFrames(consumer, 200);
+ expect(frames[0].keyframe).toBe(true);
+ expect(frames[1].keyframe).toBe(false);
+ consumer.close();
+});
+
+test("Consumer index spans MoQ frames for keyframe detection", async () => {
+ // Custom format that returns 3 samples per MoQ frame, all keyframe: false
+ const multiFormat: ContainerFormat = {
+ decode(_frame: Uint8Array): Frame[] {
+ return [
+ { data: new Uint8Array([1]), timestamp: 0 as Time.Micro, keyframe: false },
+ { data: new Uint8Array([2]), timestamp: 33_000 as Time.Micro, keyframe: false },
+ { data: new Uint8Array([3]), timestamp: 66_000 as Time.Micro, keyframe: false },
+ ];
+ },
+ };
+
+ const track = new Track("test");
+ const consumer = new Consumer(track, { format: multiFormat, latency: 500 as Time.Milli });
+
+ const group = new Group(0);
+ group.writeFrame(new Uint8Array([0x01])); // first MoQ frame → 3 samples
+ group.writeFrame(new Uint8Array([0x02])); // second MoQ frame → 3 samples
+ group.close();
+ track.writeGroup(group);
+ track.close();
+
+ const frames = await drainFrames(consumer, 200);
+ expect(frames).toHaveLength(6);
+ // Only index 0 is keyframe, rest are false
+ expect(frames.map((f) => f.keyframe)).toEqual([true, false, false, false, false, false]);
+ consumer.close();
+});
+
+test("Consumer keeps frames decoded before an error (truncated GoP)", async () => {
+ // 0xFF in the first byte signals the format to throw, simulating a stream
+ // RESET or corrupt frame mid-group. Encoding the trigger in the frame bytes
+ // keeps this deterministic when groups decode in parallel.
+ const truncatingFormat: ContainerFormat = {
+ decode(frame: Uint8Array): Frame[] {
+ if (frame[0] === 0xff) throw new Error("truncated");
+ return [{ data: frame, timestamp: frame[0] as Time.Micro, keyframe: false }];
+ },
+ };
+
+ const track = new Track("test");
+ const consumer = new Consumer(track, { format: truncatingFormat, latency: 500 as Time.Milli });
+
+ // Group 0: 2 valid frames then a tail-truncating error.
+ const g0 = new Group(0);
+ g0.writeFrame(new Uint8Array([0x01]));
+ g0.writeFrame(new Uint8Array([0x02]));
+ g0.writeFrame(new Uint8Array([0xff]));
+ g0.close();
+ track.writeGroup(g0);
+
+ // Group 1 decodes cleanly.
+ const g1 = new Group(1);
+ g1.writeFrame(new Uint8Array([0x04]));
+ g1.close();
+ track.writeGroup(g1);
+
+ track.close();
+
+ const frames = await drainFrames(consumer, 200);
+ // First 2 frames of group 0 survive; group 1 follows.
+ expect(frames.map((f) => f.group)).toEqual([0, 0, 1]);
+ expect(frames.map((f) => f.timestamp as number)).toEqual([1, 2, 4]);
+ consumer.close();
+});
+
+test("Consumer close returns undefined from next()", async () => {
+ const track = new Track("test");
+ const consumer = new Consumer(track, { format: new LegacyFormat(), latency: 500 as Time.Milli });
+
+ const promise = consumer.next();
+ consumer.close();
+
+ const result = await promise;
+ expect(result).toBeUndefined();
+});
+
+test("Consumer throws on concurrent next() calls", async () => {
+ const track = new Track("test");
+ const consumer = new Consumer(track, { format: new LegacyFormat(), latency: 500 as Time.Milli });
+
+ // First call blocks waiting for data
+ consumer.next();
+
+ // Second call should throw
+ expect(() => consumer.next()).toThrow("multiple calls to next not supported");
+ consumer.close();
+});
+
+test("Consumer skips groups via PTS-span when over latency", async () => {
+ const track = new Track("test");
+ // Zero latency = skip everything that's not the latest
+ const consumer = new Consumer(track, { format: new LegacyFormat(), latency: 0 as Time.Milli });
+
+ // Write groups with increasing timestamps. With 0 latency, any PTS span > 0 triggers skip.
+ writeGroupWithLegacyFrames(track, 0, [0 as Time.Micro]);
+ writeGroupWithLegacyFrames(track, 1, [100_000 as Time.Micro]);
+ writeGroupWithLegacyFrames(track, 2, [200_000 as Time.Micro]);
+ track.close();
+
+ const frames = await drainFrames(consumer, 300);
+ // With zero latency, the consumer should skip to the latest group
+ const groups = [...new Set(frames.map((f) => f.group))];
+ expect(groups.at(-1)).toBe(2);
+ consumer.close();
+});
+
+// --- Ordering ---
+
+test("Consumer delivers groups in sequence order regardless of arrival order", async () => {
+ const track = new Track("test");
+ const consumer = new Consumer(track, { format: new LegacyFormat(), latency: 500 as Time.Milli });
+
+ writeGroupWithLegacyFrames(track, 2, [60_000 as Time.Micro]);
+ writeGroupWithLegacyFrames(track, 0, [0 as Time.Micro]);
+ writeGroupWithLegacyFrames(track, 1, [30_000 as Time.Micro]);
+ track.close();
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const frames = await drainFrames(consumer, 500);
+ expect(frames).toHaveLength(3);
+ expect(frames[0].group).toBe(0);
+ expect(frames[1].group).toBe(1);
+ expect(frames[2].group).toBe(2);
+ consumer.close();
+});
+
+test("Consumer rejects stale groups", async () => {
+ const track = new Track("test");
+ const consumer = new Consumer(track, { format: new LegacyFormat(), latency: 500 as Time.Milli });
+
+ // Group 5 arrives first (sets active = 5)
+ writeGroupWithLegacyFrames(track, 5, [0 as Time.Micro]);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ // Group 3 is stale
+ writeGroupWithLegacyFrames(track, 3, [100_000 as Time.Micro]);
+ // Group 6 is valid
+ writeGroupWithLegacyFrames(track, 6, [30_000 as Time.Micro]);
+ track.close();
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const frames = await drainFrames(consumer, 500);
+ expect(frames).toHaveLength(2);
+ expect(frames[0].group).toBe(5);
+ expect(frames[1].group).toBe(6);
+ consumer.close();
+});
+
+// --- Group boundary signals ---
+
+test("Consumer next() returns group-done signals", async () => {
+ const track = new Track("test");
+ const consumer = new Consumer(track, { format: new LegacyFormat(), latency: 500 as Time.Milli });
+
+ writeGroupWithLegacyFrames(track, 0, [0 as Time.Micro, 33_000 as Time.Micro]);
+ writeGroupWithLegacyFrames(track, 1, [66_000 as Time.Micro]);
+ track.close();
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ const allResults: { frame: boolean; group: number }[] = [];
+ for (;;) {
+ const result = await Promise.race([
+ consumer.next(),
+ new Promise((resolve) => setTimeout(() => resolve(null), 500)),
+ ]);
+ if (result === null || result === undefined) break;
+ allResults.push({ frame: result.frame !== undefined, group: result.group });
+ }
+
+ const frameResults = allResults.filter((r) => r.frame);
+ const boundaries = allResults.filter((r) => !r.frame);
+ expect(frameResults).toHaveLength(3);
+ expect(boundaries).toHaveLength(2);
+ expect(boundaries[0].group).toBe(0);
+ expect(boundaries[1].group).toBe(1);
+ consumer.close();
+});
+
+// --- Buffered signal ---
+
+test("Consumer buffered signal updates as frames arrive", async () => {
+ const track = new Track("test");
+ const consumer = new Consumer(track, { format: new LegacyFormat(), latency: 500 as Time.Milli });
+
+ expect(consumer.buffered.peek()).toEqual([]);
+
+ writeGroupWithLegacyFrames(track, 0, [0 as Time.Micro, 33_000 as Time.Micro]);
+ writeGroupWithLegacyFrames(track, 1, [66_000 as Time.Micro, 99_000 as Time.Micro]);
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const ranges = consumer.buffered.peek();
+ expect(ranges.length).toBe(1);
+ expect(ranges[0].start).toBe(0 as Time.Milli);
+ expect((ranges[0].end as number) >= 66).toBeTruthy();
+
+ track.close();
+ consumer.close();
+});
+
+// --- Gap recovery ---
+
+test("Consumer recovers from gap in group sequence numbers", async () => {
+ const track = new Track("test");
+ const consumer = new Consumer(track, { format: new LegacyFormat(), latency: 100 as Time.Milli });
+
+ writeGroupWithLegacyFrames(track, 0, [0 as Time.Micro, 20_000 as Time.Micro]);
+ writeGroupWithLegacyFrames(track, 1, [40_000 as Time.Micro, 60_000 as Time.Micro]);
+ // Skip group 2
+ writeGroupWithLegacyFrames(track, 3, [120_000 as Time.Micro, 140_000 as Time.Micro]);
+ writeGroupWithLegacyFrames(track, 4, [160_000 as Time.Micro, 180_000 as Time.Micro]);
+ writeGroupWithLegacyFrames(track, 5, [200_000 as Time.Micro, 220_000 as Time.Micro]);
+ track.close();
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const frames = await drainFrames(consumer, 500);
+ expect(frames.length >= 4).toBeTruthy();
+ consumer.close();
+});
+
+// --- Edge cases from design review ---
+
+test("Consumer handles empty decode result without deadlock", async () => {
+ let callCount = 0;
+ const emptyThenValid: ContainerFormat = {
+ decode(_frame: Uint8Array): Frame[] {
+ callCount++;
+ if (callCount === 1) return []; // empty result
+ return [{ data: new Uint8Array([1]), timestamp: 33_000 as Time.Micro, keyframe: false }];
+ },
+ };
+
+ const track = new Track("test");
+ const consumer = new Consumer(track, { format: emptyThenValid, latency: 500 as Time.Milli });
+
+ const group = new Group(0);
+ group.writeFrame(new Uint8Array([0x01])); // empty decode
+ group.writeFrame(new Uint8Array([0x02])); // valid decode
+ group.close();
+ track.writeGroup(group);
+ track.close();
+
+ const frames = await drainFrames(consumer, 300);
+ // The empty decode produces no frames, but the second MoQ frame does.
+ // Since index 0 was never used (empty result), the first actual frame gets index=1 → keyframe false?
+ // Actually index increments per sample, and empty decode means 0 samples → index stays at 0.
+ // So the next frame's first sample gets index=0 → keyframe=true.
+ expect(frames).toHaveLength(1);
+ expect(frames[0].keyframe).toBe(true);
+ consumer.close();
+});
+
+// --- CMAF through Consumer ---
+
+test("Consumer with CmafFormat delivers correct timestamps", async () => {
+ const track = new Track("test");
+ const consumer = new Consumer(track, {
+ format: new CmafFormat(TEST_INIT),
+ latency: 500 as Time.Milli,
+ });
+
+ const group = new Group(0);
+ group.writeFrame(
+ encodeDataSegment({
+ data: new Uint8Array([0xca, 0xfe]),
+ timestamp: 0,
+ duration: 3000,
+ keyframe: true,
+ sequence: 0,
+ }),
+ );
+ group.writeFrame(
+ encodeDataSegment({
+ data: new Uint8Array([0xbe, 0xef]),
+ timestamp: 3000,
+ duration: 3000,
+ keyframe: false,
+ sequence: 0,
+ }),
+ );
+ group.close();
+ track.writeGroup(group);
+ track.close();
+
+ const frames = await drainFrames(consumer, 200);
+ expect(frames).toHaveLength(2);
+ expect(frames[0].keyframe).toBe(true); // index 0 override
+ expect(frames[1].keyframe).toBe(false); // trusts format
+ expect(frames[0].timestamp).toBe(0 as Time.Micro);
+ expect(frames[1].timestamp).toBe(33_333 as Time.Micro); // 3000/90000 * 1_000_000
+ consumer.close();
+});
diff --git a/js/hang/src/container/consumer.ts b/js/hang/src/container/consumer.ts
new file mode 100644
index 0000000000..96dbb1362e
--- /dev/null
+++ b/js/hang/src/container/consumer.ts
@@ -0,0 +1,279 @@
+import type { Time } from "@moq/net";
+import * as Moq from "@moq/net";
+import { Effect, type Getter, Signal } from "@moq/signals";
+
+import type { Format } from "./format";
+import type { BufferedRanges, Frame } from "./types";
+
+export interface ConsumerProps {
+ format: Format;
+ // Target latency in milliseconds (default: 0)
+ latency?: Signal | Time.Milli;
+}
+
+interface Group {
+ consumer: Moq.Group;
+ frames: Frame[]; // decode order
+ latest?: Time.Micro; // The timestamp of the latest known frame
+ done?: boolean; // Set when #runGroup finishes reading all frames
+}
+
+export class Consumer {
+ #track: Moq.Track;
+ #format: Format;
+ #latency: Signal;
+ #groups: Group[] = [];
+ #active?: number; // the active group sequence number
+
+ // Wake up the consumer when a new frame is available.
+ #notify?: () => void;
+
+ #buffered = new Signal([]);
+ readonly buffered: Getter = this.#buffered;
+
+ #signals = new Effect();
+
+ constructor(track: Moq.Track, props: ConsumerProps) {
+ this.#track = track;
+ this.#format = props.format;
+ this.#latency = Signal.from(props.latency ?? Moq.Time.Milli.zero);
+
+ this.#signals.spawn(this.#run.bind(this));
+ this.#signals.cleanup(() => {
+ this.#track.close();
+ for (const group of this.#groups) {
+ group.consumer.close();
+ }
+ this.#groups.length = 0;
+ });
+ }
+
+ async #run() {
+ // Start fetching groups in the background
+ for (;;) {
+ const consumer = await this.#track.recvGroup();
+ if (!consumer) break;
+
+ // To improve TTV, we always start with the first group.
+ // For higher latencies we might need to figure something else out, as its racey.
+ if (this.#active === undefined) {
+ this.#active = consumer.sequence;
+ }
+
+ if (consumer.sequence < this.#active) {
+ console.warn(`skipping old group: ${consumer.sequence} < ${this.#active}`);
+ // Skip old groups.
+ consumer.close();
+ continue;
+ }
+
+ const group: Group = {
+ consumer,
+ frames: [],
+ };
+
+ // Insert into #groups based on the group sequence number (ascending).
+ // This is used to cancel old groups.
+ this.#groups.push(group);
+ this.#groups.sort((a, b) => a.consumer.sequence - b.consumer.sequence);
+
+ // Start buffering frames from this group
+ this.#signals.spawn(this.#runGroup.bind(this, group));
+ }
+ }
+
+ async #runGroup(group: Group) {
+ try {
+ let index = 0;
+
+ for (;;) {
+ const next = await group.consumer.readFrame();
+ if (!next) break;
+
+ const decoded = this.#format.decode(next);
+
+ for (const sample of decoded) {
+ const frame: Frame = {
+ data: sample.data,
+ timestamp: sample.timestamp,
+ // Protocol invariant: groups always start at a keyframe.
+ // For index 0, we enforce this regardless of what the format reports.
+ // For index > 0, we trust the format's keyframe detection.
+ keyframe: index === 0 ? true : sample.keyframe,
+ };
+
+ index++;
+
+ group.frames.push(frame);
+
+ if (group.latest === undefined || frame.timestamp > group.latest) {
+ group.latest = frame.timestamp;
+ }
+
+ this.#updateBuffered();
+
+ if (group.consumer.sequence === this.#active) {
+ this.#notify?.();
+ this.#notify = undefined;
+ } else {
+ // Check for latency violations if this is a newer group.
+ this.#checkLatency();
+ }
+ }
+ }
+ } catch (_err) {
+ // Stop reading the group but keep already-decoded frames.
+ // A decode error or stream RESET truncates the tail of the GoP;
+ // frames decoded before the error are still valid and playable.
+ } finally {
+ group.done = true;
+
+ if (group.consumer.sequence === this.#active) {
+ // Advance to the next group.
+ this.#active += 1;
+ }
+
+ // Recompute buffered ranges now that this group is done,
+ // so consecutive done groups can merge into a single range.
+ this.#updateBuffered();
+
+ // Always notify - the consumer may need to advance past this group
+ // even if it wasn't active when this task finished.
+ this.#notify?.();
+ this.#notify = undefined;
+
+ group.consumer.close();
+ }
+ }
+
+ #checkLatency() {
+ if (this.#active === undefined) return;
+
+ let skipped = false;
+
+ // Keep skipping the oldest group while the buffered span exceeds the latency target.
+ // This also handles gaps in group sequence numbers: if #active points to a missing
+ // group, the latency span proves the missing content is too old to wait for.
+ while (this.#groups.length >= 2) {
+ const threshold = Moq.Time.Micro.fromMilli(this.#latency.peek());
+
+ // Check the difference between the earliest and latest known frames.
+ let min: number | undefined;
+ let max: number | undefined;
+
+ for (const group of this.#groups) {
+ if (group.latest === undefined) continue;
+
+ const frame = group.frames.at(0)?.timestamp ?? group.latest;
+ if (min === undefined || frame < min) min = frame;
+ if (max === undefined || group.latest > max) max = group.latest;
+ }
+
+ if (min === undefined || max === undefined) break;
+
+ const latency = max - min;
+ if (latency <= threshold) break;
+
+ const first = this.#groups.shift();
+ if (!first) break;
+ this.#active = this.#groups[0]?.consumer.sequence;
+ console.warn(`skipping slow group: ${first.consumer.sequence} -> ${this.#active}`);
+
+ first.consumer.close();
+ first.frames.length = 0;
+ skipped = true;
+ }
+
+ if (skipped) {
+ this.#updateBuffered();
+
+ // Wake up any consumers waiting for a new frame.
+ this.#notify?.();
+ this.#notify = undefined;
+ }
+ }
+
+ // Returns the next frame in order, along with the group number.
+ // If frame is undefined, the group is done.
+ async next(): Promise<{ frame: Frame | undefined; group: number } | undefined> {
+ for (;;) {
+ if (
+ this.#groups.length > 0 &&
+ this.#active !== undefined &&
+ this.#groups[0].consumer.sequence <= this.#active
+ ) {
+ const frame = this.#groups[0].frames.shift();
+ if (frame) {
+ this.#updateBuffered();
+ return { frame, group: this.#groups[0].consumer.sequence };
+ }
+
+ // Check if the group is done and then remove it.
+ // A group is removable when #active has advanced past it, OR when
+ // its #runGroup task has finished (done) and all frames are consumed.
+ // The latter handles the case where #runGroup finished before
+ // #active reached this group (e.g. after a latency skip).
+ if (this.#active > this.#groups[0].consumer.sequence || this.#groups[0].done) {
+ if (this.#groups[0].consumer.sequence === this.#active) {
+ this.#active += 1;
+ }
+
+ const group = this.#groups.shift();
+ if (group) {
+ this.#updateBuffered();
+ return { frame: undefined, group: group.consumer.sequence };
+ }
+ }
+ }
+
+ if (this.#notify) {
+ throw new Error("multiple calls to next not supported");
+ }
+
+ const abort = this.#signals.abort;
+ if (abort.aborted) return undefined;
+
+ const aborted = await new Promise((resolve) => {
+ const onAbort = () => resolve(true);
+ abort.addEventListener("abort", onAbort, { once: true });
+ this.#notify = () => {
+ abort.removeEventListener("abort", onAbort);
+ resolve(false);
+ };
+ });
+
+ this.#notify = undefined;
+ if (aborted) return undefined;
+ }
+ }
+
+ #updateBuffered(): void {
+ const ranges: BufferedRanges = [];
+
+ let prev: Group | undefined;
+
+ for (const group of this.#groups) {
+ const first = group.frames.at(0);
+ if (!first || group.latest === undefined) continue;
+
+ const start = Moq.Time.Milli.fromMicro(first.timestamp);
+ const end = Moq.Time.Milli.fromMicro(group.latest);
+
+ const last = ranges.at(-1);
+ const contiguous = prev?.done && prev.consumer.sequence + 1 === group.consumer.sequence;
+ if (last && (last.end >= start || contiguous)) {
+ last.end = Moq.Time.Milli.max(last.end, end);
+ } else {
+ ranges.push({ start, end });
+ }
+
+ prev = group;
+ }
+
+ this.#buffered.set(ranges);
+ }
+
+ close(): void {
+ this.#signals.close();
+ }
+}
diff --git a/js/hang/src/container/format.ts b/js/hang/src/container/format.ts
new file mode 100644
index 0000000000..744e74d7de
--- /dev/null
+++ b/js/hang/src/container/format.ts
@@ -0,0 +1,6 @@
+import type { Frame } from "./types";
+
+export interface Format {
+ /** Parse one MoQ frame (raw bytes) into decoded media frames. */
+ decode(frame: Uint8Array): Frame[];
+}
diff --git a/js/hang/src/container/index.ts b/js/hang/src/container/index.ts
index 2e87cf3233..11d81b49ff 100644
--- a/js/hang/src/container/index.ts
+++ b/js/hang/src/container/index.ts
@@ -1 +1,6 @@
-export * from "./codec";
+export * as Loc from "@moq/loc";
+export * as Cmaf from "./cmaf";
+export { Consumer, type ConsumerProps } from "./consumer";
+export type { Format } from "./format";
+export * as Legacy from "./legacy";
+export * from "./types";
diff --git a/js/hang/src/container/legacy.ts b/js/hang/src/container/legacy.ts
new file mode 100644
index 0000000000..2ce7ee810e
--- /dev/null
+++ b/js/hang/src/container/legacy.ts
@@ -0,0 +1,60 @@
+import type { Time } from "@moq/net";
+import * as Moq from "@moq/net";
+
+export type { BufferedRange, BufferedRanges, Frame } from "./types";
+
+import type { Format as ContainerFormat } from "./format";
+import type { Frame } from "./types";
+
+export class Format implements ContainerFormat {
+ decode(frame: Uint8Array): Frame[] {
+ const [timestamp, data] = Moq.Varint.decode(frame);
+ return [{ data, timestamp: timestamp as Time.Micro, keyframe: false }];
+ }
+}
+
+export interface Source {
+ byteLength: number;
+ copyTo(buffer: Uint8Array): void;
+}
+
+// Encode a frame as a timestamp varint followed by the payload bytes.
+export function encodeFrame(source: Uint8Array | Source, timestamp: Time.Micro): Uint8Array {
+ const timestampBytes = Moq.Varint.encode(timestamp);
+ const data = new Uint8Array(timestampBytes.byteLength + source.byteLength);
+ data.set(timestampBytes, 0);
+
+ if (source instanceof Uint8Array) {
+ data.set(source, timestampBytes.byteLength);
+ } else {
+ source.copyTo(data.subarray(timestampBytes.byteLength));
+ }
+
+ return data;
+}
+
+// A Helper class to encode frames into a track.
+export class Producer {
+ #track: Moq.Track;
+ #group?: Moq.Group;
+
+ constructor(track: Moq.Track) {
+ this.#track = track;
+ }
+
+ encode(data: Uint8Array | Source, timestamp: Time.Micro, keyframe: boolean) {
+ if (keyframe) {
+ this.#group?.close();
+ this.#group = this.#track.appendGroup();
+ } else if (!this.#group) {
+ throw new Error("must start with a keyframe");
+ }
+
+ this.#group?.writeFrame(encodeFrame(data, timestamp));
+ }
+
+ close(err?: Error) {
+ this.#track.close(err);
+ this.#group?.close();
+ }
+}
diff --git a/js/hang/src/container/types.ts b/js/hang/src/container/types.ts
new file mode 100644
index 0000000000..db2959c503
--- /dev/null
+++ b/js/hang/src/container/types.ts
@@ -0,0 +1,33 @@
+import { Time } from "@moq/net";
+
+export interface Frame {
+ data: Uint8Array;
+ timestamp: Time.Micro;
+ keyframe: boolean;
+}
+
+export interface BufferedRange {
+ start: Time.Milli;
+ end: Time.Milli;
+}
+
+export type BufferedRanges = BufferedRange[];
+
+export function mergeBufferedRanges(a: BufferedRanges, b: BufferedRanges): BufferedRanges {
+ if (a.length === 0) return b;
+ if (b.length === 0) return a;
+
+ const result: BufferedRanges = [];
+ const all = [...a, ...b].sort((x, y) => x.start - y.start);
+
+ for (const range of all) {
+ const last = result.at(-1);
+ if (last && last.end >= range.start) {
+ last.end = Time.Milli.max(last.end, range.end);
+ } else {
+ result.push({ ...range });
+ }
+ }
+
+ return result;
+}
diff --git a/js/hang/src/frame.ts b/js/hang/src/frame.ts
deleted file mode 100644
index a0fd86219a..0000000000
--- a/js/hang/src/frame.ts
+++ /dev/null
@@ -1,284 +0,0 @@
-import type * as Moq from "@moq/lite";
-import { Time } from "@moq/lite";
-import { Effect, Signal } from "@moq/signals";
-import type * as Catalog from "./catalog";
-import * as Container from "./container";
-
-export interface Source {
- byteLength: number;
- copyTo(buffer: Uint8Array): void;
-}
-
-export interface Frame {
- data: Uint8Array;
- timestamp: Time.Micro;
- keyframe: boolean;
- group: number;
-}
-
-export function encode(source: Uint8Array | Source, timestamp: Time.Micro, container?: Catalog.Container): Uint8Array {
- // Encode timestamp using the specified container format
- const timestampBytes = Container.encodeTimestamp(timestamp, container);
-
- // Allocate buffer for timestamp + payload
- const payloadSize = source instanceof Uint8Array ? source.byteLength : source.byteLength;
- const data = new Uint8Array(timestampBytes.byteLength + payloadSize);
-
- // Write timestamp header
- data.set(timestampBytes, 0);
-
- // Write payload
- if (source instanceof Uint8Array) {
- data.set(source, timestampBytes.byteLength);
- } else {
- source.copyTo(data.subarray(timestampBytes.byteLength));
- }
-
- return data;
-}
-
-// NOTE: A keyframe is always the first frame in a group, so it's not encoded on the wire.
-export function decode(buffer: Uint8Array, container?: Catalog.Container): { data: Uint8Array; timestamp: Time.Micro } {
- // Decode timestamp using the specified container format
- const [timestamp, data] = Container.decodeTimestamp(buffer, container);
- return { timestamp: timestamp as Time.Micro, data };
-}
-
-export class Producer {
- #track: Moq.Track;
- #group?: Moq.Group;
- #container?: Catalog.Container;
-
- constructor(track: Moq.Track, container?: Catalog.Container) {
- this.#track = track;
- this.#container = container;
- }
-
- encode(data: Uint8Array | Source, timestamp: Time.Micro, keyframe: boolean) {
- if (keyframe) {
- this.#group?.close();
- this.#group = this.#track.appendGroup();
- } else if (!this.#group) {
- throw new Error("must start with a keyframe");
- }
-
- this.#group?.writeFrame(encode(data, timestamp, this.#container));
- }
-
- close() {
- this.#track.close();
- this.#group?.close();
- }
-}
-
-export interface ConsumerProps {
- // Target latency in milliseconds (default: 0)
- latency?: Signal | Time.Milli;
- container?: Catalog.Container;
-}
-
-interface Group {
- consumer: Moq.Group;
- frames: Frame[]; // decode order
- latest?: Time.Micro; // The timestamp of the latest known frame
-}
-
-export class Consumer {
- #track: Moq.Track;
- #latency: Signal;
- #container?: Catalog.Container;
- #groups: Group[] = [];
- #active?: number; // the active group sequence number
-
- // Wake up the consumer when a new frame is available.
- #notify?: () => void;
-
- #signals = new Effect();
-
- constructor(track: Moq.Track, props?: ConsumerProps) {
- this.#track = track;
- this.#latency = Signal.from(props?.latency ?? Time.Milli.zero);
- this.#container = props?.container;
-
- this.#signals.spawn(this.#run.bind(this));
- this.#signals.cleanup(() => {
- this.#track.close();
- for (const group of this.#groups) {
- group.consumer.close();
- }
- this.#groups.length = 0;
- });
- }
-
- async #run() {
- // Start fetching groups in the background
- for (;;) {
- const consumer = await this.#track.nextGroup();
- if (!consumer) break;
-
- // To improve TTV, we always start with the first group.
- // For higher latencies we might need to figure something else out, as its racey.
- if (this.#active === undefined) {
- this.#active = consumer.sequence;
- }
-
- if (consumer.sequence < this.#active) {
- console.warn(`skipping old group: ${consumer.sequence} < ${this.#active}`);
- // Skip old groups.
- consumer.close();
- continue;
- }
-
- const group = {
- consumer,
- frames: [],
- };
-
- // Insert into #groups based on the group sequence number (ascending).
- // This is used to cancel old groups.
- this.#groups.push(group);
- this.#groups.sort((a, b) => a.consumer.sequence - b.consumer.sequence);
-
- // Start buffering frames from this group
- this.#signals.spawn(this.#runGroup.bind(this, group));
- }
- }
-
- async #runGroup(group: Group) {
- try {
- let keyframe = true;
-
- for (;;) {
- const next = await group.consumer.readFrame();
- if (!next) break;
-
- const { data, timestamp } = decode(next, this.#container);
- const frame = {
- data,
- timestamp,
- keyframe,
- group: group.consumer.sequence,
- };
-
- keyframe = false;
-
- group.frames.push(frame);
-
- if (!group.latest || timestamp > group.latest) {
- group.latest = timestamp;
- }
-
- if (group.consumer.sequence === this.#active) {
- this.#notify?.();
- this.#notify = undefined;
- } else {
- // Check for latency violations if this is a newer group.
- this.#checkLatency();
- }
- }
- } catch (_err) {
- // Ignore errors, we close groups on purpose to skip them.
- } finally {
- if (group.consumer.sequence === this.#active) {
- // Advance to the next group.
- this.#active += 1;
-
- this.#notify?.();
- this.#notify = undefined;
- }
-
- group.consumer.close();
- }
- }
-
- #checkLatency() {
- // We can only skip if there are at least two groups.
- if (this.#groups.length < 2) return;
-
- const first = this.#groups[0];
-
- // Check the difference between the earliest known frame and the latest known frame
- let min: number | undefined;
- let max: number | undefined;
-
- for (const group of this.#groups) {
- if (!group.latest) continue;
-
- // Use the earliest unconsumed frame in the group.
- const frame = group.frames.at(0)?.timestamp ?? group.latest;
- if (min === undefined || frame < min) {
- min = frame;
- }
-
- if (max === undefined || group.latest > max) {
- max = group.latest;
- }
- }
-
- if (min === undefined || max === undefined) return;
-
- const latency = max - min;
- if (latency < Time.Micro.fromMilli(this.#latency.peek())) return;
-
- if (this.#active !== undefined && first.consumer.sequence <= this.#active) {
- this.#groups.shift();
-
- console.warn(`skipping slow group: ${first.consumer.sequence} < ${this.#groups[0]?.consumer.sequence}`);
-
- first.consumer.close();
- first.frames.length = 0;
- }
-
- // Advance to the next known group.
- // NOTE: Can't be undefined, because we checked above.
- this.#active = this.#groups[0]?.consumer.sequence;
-
- // Wake up any consumers waiting for a new frame.
- this.#notify?.();
- this.#notify = undefined;
- }
-
- async decode(): Promise