diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3ce1b0a97..9483d6fb9 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -53,7 +53,7 @@ jobs: - if: runner.environment == 'github-hosted' uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main - if: runner.environment == 'github-hosted' - uses: DeterminateSystems/flake-checker-action@9ee1c5473f1abdfb299f6c4f0fab813147c97fe3 # main + uses: DeterminateSystems/flake-checker-action@a0f068d37b542f3150564fbf1164fec2d958ee11 # main # GitHub-hosted fallback caches Rust via the Actions cache. The self-hosted # box runs the heavy Rust CI (clippy/doc/test) as crane derivations via diff --git a/.github/workflows/release-kt-ffi.yml b/.github/workflows/release-kt-ffi.yml index ff5ce85e4..3e22c49e8 100644 --- a/.github/workflows/release-kt-ffi.yml +++ b/.github/workflows/release-kt-ffi.yml @@ -155,7 +155,7 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - name: Download per-target libs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 diff --git a/.github/workflows/update-flake.yml b/.github/workflows/update-flake.yml index 2a7099acb..cd96544c1 100644 --- a/.github/workflows/update-flake.yml +++ b/.github/workflows/update-flake.yml @@ -29,7 +29,7 @@ jobs: run: dirname "$(command -v nix)" >> "$GITHUB_PATH" - name: Update flake.lock - uses: DeterminateSystems/update-flake-lock@b83e0671a67dfd774680fb1beaa1497ef7e58bfc # main + uses: DeterminateSystems/update-flake-lock@fd9359ac79d0e912f1b4b947a48470b3e2799b56 # main with: pr-title: "Update flake.lock" pr-labels: | diff --git a/CLAUDE.md b/CLAUDE.md index 8c809c396..fd8b87a3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,9 +86,19 @@ This root file holds only cross-cutting rules that apply everywhere (writing sty - 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. +## Deprecation + +Don't document deprecated flags, options, or APIs. User-facing docs (`/doc`), `--help`, and doc comments should describe only the current/canonical surface, so a reader is steered to the right thing and never learns the dead one. Keep the deprecated path *working* but invisible: + +- A deprecated CLI flag stays a hidden alias (clap `alias = "..."`, or a separate `#[arg(..., hide = true)]` when it needs its own deprecation warning). No `--help` entry, no "deprecated, use X" note in the doc comment. +- A deprecated public item gets `#[doc(hidden)]` (Rust) / `@internal` or omission (JS) so it drops off the published docs. +- Remove the example invocations and prose that mention it from `/doc`. + +The rename/removal rationale lives in the commit message and PR description, not in docs that users read. A runtime warning when someone *uses* the deprecated path is fine (it fires on use, it isn't documentation); a standing note that advertises the dead name is not. + ## 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. +LLM-authored prose visible to humans (PR descriptions, PR comments, review replies) should end with the agent model, e.g. `(Written by GPT-5)`. 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 @@ -111,7 +121,8 @@ Favor composable building blocks over one-off functions. A handful of orthogonal Then future-proof what you do expose so additions don't force a breaking change: -- **Config structs consumers construct**: add `#[non_exhaustive]` and a `Default` or constructor. New optional fields then stay additive (callers build via `default()`/`new()` + field set, not struct literals). Prefer adding a field to an existing `#[non_exhaustive]` config over adding a function parameter. +- **Config structs consumers construct with `pub` fields**: add `#[non_exhaustive]` and a `Default` or constructor. New optional fields then stay additive (callers build via `default()`/`new()` + field set, not struct literals). Prefer adding a field to an existing `#[non_exhaustive]` config over adding a function parameter. This applies only when the struct exposes `pub` fields, since `#[non_exhaustive]` is what blocks the struct-literal path. A struct with all-private fields built through a builder (`default()` + chained `.with_x()` methods) already prevents struct literals, so `#[non_exhaustive]` is redundant there; don't add it. +- **Take an options struct/object, not positional parameters, whenever a function or constructor could plausibly gain more knobs later.** A single `Config`/options bag (Rust struct, TS interface) lets you add fields without changing the signature; positional params force a breaking change (or an awkward `(track, undefined, opts)` call) the moment a second option shows up. Reach for it even when there's only one option today: a lone `compression: bool` arg is a future breaking change waiting to happen, whereas `Config { compression }` absorbs the next field for free. This applies in both languages, not just where `#[non_exhaustive]` does. - **Public enums that may gain variants**: add `#[non_exhaustive]` so external `match`es keep compiling. - **Name by role, not by today's only implementation** (`capture::Config`, `publish_capture`, not `CameraConfig`/`publish_camera`) so a second implementation slots in without a rename. Don't bundle generic options under a specific-case name. - **Namespace with modules; keep type names short.** Split a growing crate into role modules (`capture`, `encode`, `decode`) and let each own short, unprefixed names. The module already supplies the prefix, so `encode::Config` beats `EncoderConfig` and `encode::Producer` beats `VideoProducer`. But don't nest a module whose name echoes its main type: `encode::encoder::Encoder` stutters; re-export the type flat so it reads `encode::Encoder`. Re-export the public types at the role-module level (`pub use encoder::{Encoder, Config}`) and keep the file-level module (`mod encoder`) private. @@ -195,4 +206,4 @@ Update them with `gh pr edit --title "..." --body "..."` whenever the scop - 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. +When you edit a PR description you authored, keep the agent model marker so reviewers still know the body wasn't human-authored. diff --git a/Cargo.lock b/Cargo.lock index ef606ced5..abb8078a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -74,7 +83,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags 2.13.0", + "bitflags", "cfg-if", "libc", ] @@ -104,6 +113,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "1.0.0" @@ -140,7 +155,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -151,14 +166,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arc-swap" @@ -189,9 +204,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "askama" @@ -217,10 +232,10 @@ dependencies = [ "memchr", "proc-macro2", "quote", - "rustc-hash 2.1.2", + "rustc-hash", "serde", "serde_derive", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -259,7 +274,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "synstructure", ] @@ -271,7 +286,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -315,7 +330,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -558,65 +573,22 @@ dependencies = [ "serde", ] -[[package]] -name = "bindgen" -version = "0.65.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" -dependencies = [ - "bitflags 1.3.2", - "cexpr", - "clang-sys", - "lazy_static", - "lazycell", - "log", - "peeking_take_while", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex 1.3.0", - "syn 2.0.118", - "which", -] - -[[package]] -name = "bindgen" -version = "0.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" -dependencies = [ - "bitflags 2.13.0", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex 1.3.0", - "syn 2.0.118", -] - [[package]] name = "bindgen" version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.0", + "bitflags", "cexpr", "clang-sys", "itertools 0.13.0", "proc-macro2", "quote", "regex", - "rustc-hash 2.1.2", - "shlex 1.3.0", - "syn 2.0.118", + "rustc-hash", + "shlex", + "syn 2.0.117", ] [[package]] @@ -630,15 +602,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "blake3" @@ -674,9 +640,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", ] @@ -696,7 +662,7 @@ version = "4.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0e3bc837369e9e662d3845c374ac3771b054d4bc2dee5457591198d16d684c" dependencies = [ - "bitflags 2.13.0", + "bitflags", "boring-sys", "foreign-types", "libc", @@ -709,51 +675,12 @@ version = "4.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15aad385759d3da2737772aefb391332227bef7cb71fb85c106cadd9d1e63a98" dependencies = [ - "bindgen 0.72.1", + "bindgen", "cmake", "fs_extra", "fslock", ] -[[package]] -name = "boytacean" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "333afc1e8f6a0bbba0a916346a2fca124c153b6ec42f5c8d795c05126503ee0b" -dependencies = [ - "boytacean-common", - "boytacean-encoding", - "boytacean-hashing", - "built", - "chrono", - "regex", -] - -[[package]] -name = "boytacean-common" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fe79b9ef2249700e02747de11cbc048870767705b8c31615a820bda6d94f81f" - -[[package]] -name = "boytacean-encoding" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7edc2850784c0e2b0a55e7d4e7823ef60c4493be021c13f6010fca19d75edfa" -dependencies = [ - "boytacean-common", - "boytacean-hashing", -] - -[[package]] -name = "boytacean-hashing" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "221540d0cb3bc7222b46c061e330ce05622ed739ed2f7247864ab7ec5f63dfa3" -dependencies = [ - "boytacean-common", -] - [[package]] name = "bs58" version = "0.5.1" @@ -763,27 +690,12 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "built" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" -dependencies = [ - "cargo-lock", -] - [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - [[package]] name = "byteorder" version = "1.5.0" @@ -810,25 +722,13 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.3" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ "serde_core", ] -[[package]] -name = "cargo-lock" -version = "10.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06acb4f71407ba205a07cb453211e0e6a67b21904e47f6ba1f9589e38f2e454" -dependencies = [ - "semver", - "serde", - "toml 0.8.23", - "url", -] - [[package]] name = "cargo-platform" version = "0.1.9" @@ -853,34 +753,21 @@ dependencies = [ ] [[package]] -name = "cbindgen" -version = "0.29.4" +name = "cast" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" -dependencies = [ - "clap", - "heck", - "indexmap 2.14.0", - "log", - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn 2.0.118", - "tempfile", - "toml 0.9.12+spec-1.1.0", -] +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.65" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 2.0.1", + "shlex", ] [[package]] @@ -940,9 +827,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.8" +version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" dependencies = [ "smallvec", "target-lexicon", @@ -985,6 +872,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1003,7 +917,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading 0.8.9", + "libloading", ] [[package]] @@ -1037,7 +951,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1057,9 +971,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.4" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" [[package]] name = "cobs" @@ -1209,7 +1123,7 @@ version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 2.13.0", + "bitflags", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -1289,6 +1203,41 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -1313,6 +1262,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -1328,6 +1287,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -1387,15 +1352,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "cudarc" -version = "0.19.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42310153e06cf4cd532901f7096beb27504d681736a29ee90728ae4e2d93b2a8" -dependencies = [ - "libloading 0.9.0", -] - [[package]] name = "curve25519-dalek" version = "5.0.0-rc.0" @@ -1422,7 +1378,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1456,7 +1412,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1469,7 +1425,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1480,7 +1436,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1491,7 +1447,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1537,7 +1493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -1623,7 +1579,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1653,7 +1609,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1663,7 +1619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1676,7 +1632,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1698,7 +1654,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.117", "unicode-xid", ] @@ -1735,7 +1691,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.1", + "block-buffer 0.12.0", "crypto-common 0.2.2", ] @@ -1770,7 +1726,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags", "block2", "libc", "objc2", @@ -1778,13 +1734,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1933,7 +1889,7 @@ checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1945,7 +1901,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1981,7 +1937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2023,7 +1979,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" dependencies = [ - "foldhash", + "foldhash 0.2.0", "libm", "portable-atomic", "siphasher", @@ -2100,6 +2056,12 @@ 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" @@ -2124,7 +2086,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -2144,9 +2106,9 @@ dependencies = [ [[package]] name = "foundations" -version = "5.7.3" +version = "5.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92619d6c02aeb0106bea8717702afa888a400088c681fd0be080dc1e029afb65" +checksum = "f8842ece95ec0b71f0dbf9cb1da218ebdc6a40536bb97ea9c1974978058912b9" dependencies = [ "anyhow", "cf-rustracing", @@ -2184,14 +2146,14 @@ dependencies = [ [[package]] name = "foundations-macros" -version = "5.7.3" +version = "5.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1a9a185152575916042458bd8b6edb06e96a71af45e0ec63cc5f7b222c21dfb" +checksum = "ef32f15128fcc7706efb662b46b92bcad51c1e92cd691320864efde9e5e218c0" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -2320,7 +2282,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -2360,9 +2322,9 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.9" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" dependencies = [ "cc", "cfg-if", @@ -2413,27 +2375,30 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +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.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" dependencies = [ + "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -2471,7 +2436,7 @@ version = "0.20.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" dependencies = [ - "bitflags 2.13.0", + "bitflags", "futures-channel", "futures-core", "futures-executor", @@ -2496,7 +2461,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -2590,7 +2555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94668bc2592732b8c2b653668ae41211d45988fb61264888b9c2d545d4bd826d" dependencies = [ "chrono", - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit", ] [[package]] @@ -2632,9 +2597,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -2655,6 +2620,17 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "253b313319f7109de64e480ffb606f89475cd758bae82e096e00c5d95341d30e" +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hang" version = "0.19.2" @@ -2689,6 +2665,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -2697,7 +2682,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2708,7 +2693,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2834,15 +2819,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "hostname" version = "0.4.2" @@ -2856,9 +2832,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", @@ -2987,9 +2963,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -3165,6 +3141,12 @@ 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" @@ -3200,9 +3182,9 @@ dependencies = [ [[package]] name = "igd-next" -version = "0.17.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +checksum = "bac9a3c8278f43b4cd8463380f4a25653ac843e5b177e1d3eaf849cc9ba10d4d" dependencies = [ "attohttpc", "bytes", @@ -3247,7 +3229,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ - "bitflags 2.13.0", + "bitflags", "inotify-sys", "libc", ] @@ -3325,7 +3307,7 @@ dependencies = [ "derive_more 2.1.1", "ed25519-dalek", "futures-util", - "getrandom 0.4.3", + "getrandom 0.4.2", "hickory-resolver", "http", "ipnet", @@ -3345,8 +3327,8 @@ dependencies = [ "portable-atomic", "portmapper", "rand 0.10.1", - "reqwest 0.13.4", - "rustc-hash 2.1.2", + "reqwest 0.13.3", + "rustc-hash", "rustls", "rustls-pki-types", "serde", @@ -3372,7 +3354,7 @@ dependencies = [ "data-encoding-macro", "derive_more 2.1.1", "ed25519-dalek", - "getrandom 0.4.3", + "getrandom 0.4.2", "n0-error", "rand 0.10.1", "serde", @@ -3428,7 +3410,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3442,7 +3424,7 @@ dependencies = [ "cfg_aliases", "data-encoding", "derive_more 2.1.1", - "getrandom 0.4.3", + "getrandom 0.4.2", "hickory-resolver", "http", "http-body-util", @@ -3460,7 +3442,7 @@ dependencies = [ "pin-project", "postcard", "rand 0.10.1", - "reqwest 0.13.4", + "reqwest 0.13.3", "rustls", "rustls-pki-types", "serde", @@ -3498,7 +3480,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3574,7 +3556,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3602,7 +3584,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3699,7 +3681,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.13.0", + "bitflags", "libc", ] @@ -3713,10 +3695,10 @@ dependencies = [ ] [[package]] -name = "lazycell" -version = "1.3.0" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" @@ -3734,42 +3716,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "libloading" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" -dependencies = [ - "cfg-if", - "windows-link", -] - [[package]] name = "libm" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libmoq" -version = "0.3.8" -dependencies = [ - "anyhow", - "bytes", - "cbindgen", - "hang", - "moq-audio", - "moq-mux", - "moq-native", - "moq-net", - "moq-video", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tracing", - "url", -] - [[package]] name = "linked-hash-map" version = "0.5.6" @@ -3816,9 +3768,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "loom" @@ -3934,9 +3886,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -4003,51 +3955,6 @@ dependencies = [ "url", ] -[[package]] -name = "moq-boy" -version = "0.2.21" -dependencies = [ - "anyhow", - "boytacean", - "bytes", - "clap", - "hang", - "moq-audio", - "moq-json", - "moq-mux", - "moq-native", - "moq-net", - "moq-video", - "serde", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "moq-cli" -version = "0.7.34" -dependencies = [ - "anyhow", - "axum", - "axum-server", - "bytes", - "clap", - "hang", - "humantime", - "moq-audio", - "moq-hls", - "moq-mux", - "moq-native", - "moq-video", - "rustls", - "sd-notify", - "tokio", - "tower-http", - "tracing", - "url", -] - [[package]] name = "moq-ffi" version = "0.2.24" @@ -4067,6 +3974,15 @@ dependencies = [ "url", ] +[[package]] +name = "moq-flate" +version = "0.1.0" +dependencies = [ + "bytes", + "flate2", + "thiserror 2.0.18", +] + [[package]] name = "moq-gst" version = "0.2.8" @@ -4106,19 +4022,20 @@ dependencies = [ "sd-notify", "thiserror 2.0.18", "tokio", - "tower-http", + "tower-http 0.7.0", "tracing", "url", ] [[package]] name = "moq-json" -version = "0.0.4" +version = "0.1.0" dependencies = [ "bytes", - "flate2", + "criterion", "json-patch", "kio", + "moq-flate", "moq-net", "serde", "serde_json", @@ -4270,7 +4187,7 @@ dependencies = [ "tokio", "tokio-rustls", "toml 1.1.2+spec-1.1.0", - "tower-http", + "tower-http 0.7.0", "tower-service", "tracing", "url", @@ -4297,7 +4214,7 @@ dependencies = [ "str0m", "thiserror 2.0.18", "tokio", - "tower-http", + "tower-http 0.7.0", "tracing", "url", "uuid", @@ -4323,7 +4240,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-rustls", - "tower-http", + "tower-http 0.7.0", "tracing", "url", ] @@ -4347,7 +4264,7 @@ dependencies = [ "srt-tokio", "thiserror 2.0.18", "tokio", - "tower-http", + "tower-http 0.7.0", "tracing", "url", ] @@ -4381,61 +4298,13 @@ dependencies = [ "moq-token", ] -[[package]] -name = "moq-vaapi" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c32870bce5b00614c94229081d57f0bc93877321ec1868ad21fcb547c54e2a59" -dependencies = [ - "anyhow", - "bindgen 0.70.1", - "bitflags 2.13.0", - "log", - "pkg-config", - "regex", - "thiserror 1.0.69", -] - -[[package]] -name = "moq-video" -version = "0.0.5" -dependencies = [ - "anyhow", - "block2", - "bytes", - "cudarc", - "dispatch2", - "hang", - "libloading 0.8.9", - "moq-mux", - "moq-net", - "moq-vaapi", - "nvidia-video-codec-sdk", - "objc2", - "objc2-av-foundation", - "objc2-core-foundation", - "objc2-core-media", - "objc2-core-video", - "objc2-foundation", - "objc2-screen-capture-kit", - "objc2-video-toolbox", - "openh264", - "thiserror 2.0.18", - "tokio", - "tracing", - "v4l", - "windows", - "yuv", - "zune-jpeg", -] - [[package]] name = "moq-wasm" version = "0.0.0" dependencies = [ "bytes", "console_error_panic_hook", - "getrandom 0.4.3", + "getrandom 0.4.2", "js-sys", "moq-net", "thiserror 2.0.18", @@ -4494,7 +4363,7 @@ checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -4529,22 +4398,13 @@ dependencies = [ "n0-future", ] -[[package]] -name = "nasm-rs" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" -dependencies = [ - "log", -] - [[package]] name = "ndk" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.0", + "bitflags", "jni-sys 0.3.1", "log", "ndk-sys", @@ -4573,7 +4433,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "bitflags 2.13.0", + "bitflags", "byteorder", "derive_builder", "getset", @@ -4593,7 +4453,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -4638,7 +4498,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" dependencies = [ - "bitflags 2.13.0", + "bitflags", "libc", "log", "netlink-packet-core", @@ -4650,7 +4510,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" dependencies = [ - "bitflags 2.13.0", + "bitflags", "libc", "log", "netlink-packet-core", @@ -4726,7 +4586,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.13.0", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -4770,7 +4630,7 @@ dependencies = [ "noq-proto", "noq-udp", "pin-project-lite", - "rustc-hash 2.1.2", + "rustc-hash", "rustls", "socket2 0.6.4", "thiserror 2.0.18", @@ -4792,13 +4652,13 @@ dependencies = [ "derive_more 2.1.1", "enum-assoc", "fastbloom 0.17.0", - "getrandom 0.4.3", + "getrandom 0.4.2", "identity-hash", "lru-slab", "rand 0.10.1", "rand_pcg", "ring", - "rustc-hash 2.1.2", + "rustc-hash", "rustls", "rustls-pki-types", "rustls-platform-verifier 0.7.0", @@ -4829,7 +4689,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.13.0", + "bitflags", "fsevent-sys", "inotify", "kqueue", @@ -4847,7 +4707,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.13.0", + "bitflags", ] [[package]] @@ -4856,7 +4716,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4922,7 +4782,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -4995,7 +4855,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -5016,16 +4876,6 @@ dependencies = [ "scuffle-workspace-hack", ] -[[package]] -name = "nvidia-video-codec-sdk" -version = "0.4.0" -source = "git+https://github.com/kixelated/nvidia-video-codec-sdk.git?branch=dynamic-loading#72f6eca3a0d66a18667a4e498b712e9ffd311f22" -dependencies = [ - "cudarc", - "lazy_static", - "libloading 0.8.9", -] - [[package]] name = "objc2" version = "0.6.4" @@ -5041,7 +4891,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags 2.13.0", + "bitflags", "libc", "objc2", "objc2-core-audio", @@ -5056,7 +4906,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" dependencies = [ - "bitflags 2.13.0", + "bitflags", "block2", "dispatch2", "objc2", @@ -5078,7 +4928,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "bitflags 2.13.0", + "bitflags", "objc2", "objc2-foundation", ] @@ -5102,7 +4952,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.13.0", + "bitflags", "objc2", ] @@ -5112,7 +4962,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags", "block2", "dispatch2", "libc", @@ -5125,7 +4975,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.0", + "bitflags", "dispatch2", "objc2", "objc2-core-foundation", @@ -5148,8 +4998,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" dependencies = [ - "bitflags 2.13.0", - "block2", + "bitflags", "dispatch2", "objc2", "objc2-core-audio", @@ -5164,13 +5013,11 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.13.0", - "block2", + "bitflags", "objc2", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", - "objc2-metal", ] [[package]] @@ -5179,7 +5026,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" dependencies = [ - "bitflags 2.13.0", + "bitflags", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -5199,7 +5046,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags", "block2", "libc", "objc2", @@ -5223,7 +5070,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.0", + "bitflags", "objc2", "objc2-core-foundation", ] @@ -5240,45 +5087,15 @@ dependencies = [ "objc2-core-media", ] -[[package]] -name = "objc2-metal" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" -dependencies = [ - "bitflags 2.13.0", - "objc2", - "objc2-foundation", -] - [[package]] name = "objc2-quartz-core" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.0", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-screen-capture-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74b7c5390f477482f001bc354d6571a70db7e4f8d5288e860c45521fbce11394" -dependencies = [ - "bitflags 2.13.0", - "block2", - "dispatch2", - "libc", + "bitflags", "objc2", - "objc2-av-foundation", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-media", "objc2-foundation", - "objc2-uniform-type-identifiers", ] [[package]] @@ -5287,7 +5104,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags 2.13.0", + "bitflags", "objc2", "objc2-core-foundation", ] @@ -5308,7 +5125,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ - "bitflags 2.13.0", + "bitflags", "dispatch2", "libc", "objc2", @@ -5316,33 +5133,6 @@ dependencies = [ "objc2-security", ] -[[package]] -name = "objc2-uniform-type-identifiers" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7902ac02859fc1f7045f8b598c63f1ae0cc7efeaa06a9bc9f3d9a3c955974fa4" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-video-toolbox" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05bf9a3c14831a7d9641b0d81d87dd913ee238a012b2fde27db5a84b56f5df3e" -dependencies = [ - "bitflags 2.13.0", - "block2", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-media", - "objc2-core-video", - "objc2-foundation", - "objc2-metal", -] - [[package]] name = "object" version = "0.37.3" @@ -5384,31 +5174,16 @@ 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 = "openh264" -version = "0.9.3" +name = "oorandom" +version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a12b82c14f702c2cece4e0fc28896c6a6bed5317dc13448c86ac41df91a6f82" -dependencies = [ - "openh264-sys2", - "wide", -] +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] -name = "openh264-sys2" -version = "0.9.6" +name = "opaque-debug" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa9e072e9b270f3b291c80488dc160abc31ecc214ab3bfde937213cfd8c83b32" -dependencies = [ - "cc", - "nasm-rs", - "walkdir", -] +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl-macros" @@ -5418,7 +5193,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -5506,6 +5281,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "papaya" version = "0.2.4" @@ -5568,12 +5353,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "peeking_take_while" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" - [[package]] name = "pem" version = "3.0.6" @@ -5645,7 +5424,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -5716,6 +5495,34 @@ dependencies = [ "time", ] +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "pollster" version = "0.4.0" @@ -5793,7 +5600,7 @@ checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -5838,7 +5645,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -5856,7 +5663,29 @@ 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", + "toml_edit", +] + +[[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]] @@ -5874,7 +5703,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" dependencies = [ - "bitflags 2.13.0", + "bitflags", "hex", "procfs-core", "rustix 0.38.44", @@ -5886,7 +5715,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" dependencies = [ - "bitflags 2.13.0", + "bitflags", "hex", ] @@ -5948,7 +5777,7 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bitflags 2.13.0", + "bitflags", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -5959,9 +5788,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.4" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", "prost-derive", @@ -5969,15 +5798,15 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.4" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -6030,9 +5859,9 @@ dependencies = [ [[package]] name = "quiche" -version = "0.29.1" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1f7537688130c9bb919e2afdc4f89fae5e71c11001573f175fd79b4dc3ffae9" +checksum = "bad15d1a90dc1de62c4fdf46ebc68511e0959bb2fa35e16bfce50f462a4aa539" dependencies = [ "boring", "bytes", @@ -6074,7 +5903,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.2", + "rustc-hash", "rustls", "socket2 0.6.4", "thiserror 2.0.18", @@ -6096,7 +5925,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash 2.1.2", + "rustc-hash", "rustls", "rustls-pki-types", "rustls-platform-verifier 0.6.2", @@ -6123,9 +5952,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -6170,7 +5999,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom 0.4.3", + "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -6242,7 +6071,27 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.0", + "bitflags", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", ] [[package]] @@ -6266,7 +6115,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags", ] [[package]] @@ -6286,7 +6135,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -6347,7 +6196,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -6358,9 +6207,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", @@ -6384,7 +6233,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -6503,12 +6352,6 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - [[package]] name = "rustc-hash" version = "2.1.2" @@ -6539,7 +6382,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6552,11 +6395,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6615,7 +6458,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6636,7 +6479,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6669,15 +6512,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - [[package]] name = "same-file" version = "1.0.6" @@ -6749,7 +6583,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -6762,7 +6596,7 @@ dependencies = [ "crc", "log", "rand 0.9.4", - "rustc-hash 2.1.2", + "rustc-hash", "slab", "thiserror 2.0.18", ] @@ -6807,7 +6641,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b04b276c2f79846b7968abe6f87cedf951e06fd2a2b72d99c457e85d7e40f3fb" dependencies = [ - "bitflags 2.13.0", + "bitflags", "byteorder", "bytes", "nutype-enum", @@ -6851,7 +6685,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -6875,7 +6709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6931,7 +6765,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -6959,15 +6793,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_spanned" -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" @@ -7018,7 +6843,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7132,12 +6957,6 @@ 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" @@ -7192,7 +7011,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" dependencies = [ - "bitflags 2.13.0", + "bitflags", ] [[package]] @@ -7299,9 +7118,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smawk" -version = "0.3.3" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" @@ -7320,7 +7139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7337,7 +7156,7 @@ checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7393,7 +7212,7 @@ dependencies = [ "aes", "array-init", "arraydeque", - "bitflags 2.13.0", + "bitflags", "bytes", "cipher", "ctr", @@ -7520,7 +7339,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7542,9 +7361,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -7568,7 +7387,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7577,7 +7396,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.0", + "bitflags", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -7625,9 +7444,9 @@ checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" [[package]] name = "target-lexicon" -version = "0.13.5" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "task-killswitch" @@ -7647,10 +7466,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7659,7 +7478,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7697,7 +7516,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7708,7 +7527,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7804,6 +7623,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -7844,7 +7673,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7947,7 +7776,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", - "getrandom 0.4.3", + "getrandom 0.4.2", "http", "httparse", "rand 0.10.1", @@ -7959,18 +7788,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "toml" -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" @@ -7979,7 +7796,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.14.0", "serde_core", - "serde_spanned 1.1.1", + "serde_spanned", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", @@ -7994,22 +7811,13 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap 2.14.0", "serde_core", - "serde_spanned 1.1.1", + "serde_spanned", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", "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]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -8030,23 +7838,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "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" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", @@ -8063,12 +7857,6 @@ dependencies = [ "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.1.1+spec-1.1.0" @@ -8138,7 +7926,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.13.0", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +dependencies = [ + "bitflags", "bytes", "futures-core", "futures-util", @@ -8153,10 +7963,8 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower", "tower-layer", "tower-service", - "url", ] [[package]] @@ -8202,7 +8010,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -8262,7 +8070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -8327,9 +8135,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.1" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unarray" @@ -8351,9 +8159,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.3" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" @@ -8437,7 +8245,7 @@ dependencies = [ "indexmap 2.14.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -8452,7 +8260,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.118", + "syn 2.0.117", "toml 0.9.12+spec-1.1.0", "uniffi_meta", ] @@ -8558,31 +8366,11 @@ version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] -[[package]] -name = "v4l" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8fbfea44a46799d62c55323f3c55d06df722fbe577851d848d328a1041c3403" -dependencies = [ - "bitflags 1.3.2", - "libc", - "v4l2-sys-mit", -] - -[[package]] -name = "v4l2-sys-mit" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6779878362b9bacadc7893eac76abe69612e8837ef746573c4a5239daf11990b" -dependencies = [ - "bindgen 0.65.1", -] - [[package]] name = "valuable" version = "0.1.1" @@ -8646,7 +8434,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -8676,11 +8464,20 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.4+wasi-0.2.12" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "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 = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -8725,7 +8522,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -8738,6 +8535,28 @@ 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.5.0" @@ -8751,6 +8570,18 @@ 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 = "wasmtimer" version = "0.4.3" @@ -8863,9 +8694,9 @@ dependencies = [ [[package]] name = "web-transport-quiche" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f79670e02d23e233f96f53a05d5cbaeb0dfcc37ee4175c93f10a8a5389ee4" +checksum = "1c946eccf30928aa98aa0454d2929f48ad952e5335e2ecbf4d0eb6263f1f98ce" dependencies = [ "boring", "bytes", @@ -8937,18 +8768,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -8962,28 +8793,6 @@ dependencies = [ "nom 7.1.3", ] -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", -] - -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch", -] - [[package]] name = "widestring" version = "1.2.1" @@ -9012,7 +8821,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9083,7 +8892,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9094,7 +8903,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9423,12 +9232,100 @@ dependencies = [ "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.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 = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "wmi" version = "0.18.4" @@ -9547,9 +9444,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -9564,37 +9461,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "synstructure", ] -[[package]] -name = "yuv" -version = "0.8.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d85a782d94ee43f078bcfd6fa82d4e6a5b2d1cfbbad168e4df5a9f7b39ef48c" -dependencies = [ - "num-traits", -] - [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9614,7 +9502,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "synstructure", ] @@ -9635,7 +9523,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9668,7 +9556,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9704,18 +9592,3 @@ dependencies = [ "cc", "pkg-config", ] - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] diff --git a/Cargo.toml b/Cargo.toml index 489a58912..8cfa2faa1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "rs/moq-boy", "rs/moq-cli", "rs/moq-ffi", + "rs/moq-flate", "rs/moq-gst", "rs/moq-hls", "rs/moq-json", @@ -35,6 +36,7 @@ default-members = [ "rs/moq-cli", # "rs/moq-ffi", # requires Python/maturin # "rs/moq-gst", # requires GStreamer + "rs/moq-flate", "rs/moq-hls", "rs/moq-json", "rs/moq-loc", @@ -61,8 +63,9 @@ flate2 = "1.1" hang = { version = "0.19", path = "rs/hang" } kio = { version = "0.4", path = "rs/kio" } moq-audio = { version = "0.0.5", path = "rs/moq-audio" } +moq-flate = { version = "0.1.0", path = "rs/moq-flate" } moq-hls = { version = "0.0.1", path = "rs/moq-hls", default-features = false } -moq-json = { version = "0.0.4", path = "rs/moq-json" } +moq-json = { version = "0.1.0", path = "rs/moq-json" } moq-loc = { version = "0.1", path = "rs/moq-loc" } moq-msf = { version = "0.2", path = "rs/moq-msf" } moq-mux = { version = "0.6", path = "rs/moq-mux" } diff --git a/bun.lock b/bun.lock index d03bf6f63..9ca0ea30c 100644 --- a/bun.lock +++ b/bun.lock @@ -1,11 +1,10 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "moq", "devDependencies": { - "@biomejs/biome": "^2.4.16", + "@biomejs/biome": "^2.5.0", "concurrently": "^10.0.3", "publint": "^0.3.21", "remark-cli": "^12.0.1", @@ -26,8 +25,8 @@ "@moq/boy": "workspace:^", }, "devDependencies": { - "esbuild": "^0.28.0", - "typescript": "^6.0.3", + "esbuild": "^0.28.1", + "typescript": "7.0.1-rc", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12", }, @@ -42,14 +41,14 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.20", - "@tailwindcss/vite": "^4.3.0", - "esbuild": "^0.28.0", + "@tailwindcss/vite": "^4.3.1", + "esbuild": "^0.28.1", "highlight.js": "^11.11.1", - "open": "^10.2.0", + "open": "^11.0.0", "solid-element": "^1.9.1", "solid-js": "^1.9.13", "tailwindcss": "^4.1.13", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12", }, @@ -59,7 +58,7 @@ "version": "0.1.0", "devDependencies": { "vitepress": "^1.6.4", - "wrangler": "^4.99.0", + "wrangler": "^4.103.0", }, }, "js/clock": { @@ -74,8 +73,22 @@ "@moq/net": "workspace:*", }, "devDependencies": { - "@types/node": "^25.9.2", - "typescript": "^6.0.3", + "@types/node": "^26.0.0", + "typescript": "7.0.1-rc", + }, + }, + "js/flate": { + "name": "@moq/flate", + "version": "0.1.0", + "dependencies": { + "pako": "^2.2.0", + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "@types/pako": "^2.0.4", + "fflate": "^0.8.2", + "rimraf": "^6.1.3", + "typescript": "7.0.1-rc", }, }, "js/hang": { @@ -97,23 +110,21 @@ "@typescript/lib-dom": "npm:@types/web@^0.0.350", "fast-glob": "^3.3.3", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, }, "js/json": { "name": "@moq/json", - "version": "0.0.3", + "version": "0.1.0", "dependencies": { + "@moq/flate": "workspace:^", "@moq/net": "workspace:^", "@moq/signals": "workspace:^", - "pako": "^2.2.0", }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/pako": "^2.0.4", - "fflate": "^0.8.2", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, "peerDependencies": { "zod": "^4.0.0", @@ -128,7 +139,7 @@ "devDependencies": { "@types/bun": "^1.3.14", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, }, "js/moq-boy": { @@ -143,10 +154,10 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "rimraf": "^6.1.3", "solid-js": "^1.9.13", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12", }, @@ -160,7 +171,7 @@ }, "devDependencies": { "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, }, "js/net": { @@ -173,10 +184,10 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/node": "^25.9.2", + "@types/node": "^26.0.0", "@typescript/lib-dom": "npm:@types/web@^0.0.350", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite-plugin-html": "^3.2.2", }, "peerDependencies": { @@ -196,9 +207,9 @@ "@types/audioworklet": "^0.0.100", "@types/bun": "^1.3.14", "@typescript/lib-dom": "npm:@types/web@^0.0.350", - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", }, }, @@ -211,7 +222,7 @@ "react": "^19.0.0", "rimraf": "^6.1.3", "solid-js": "^1.9.13", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, "peerDependencies": { "@types/react": "^19.2.17", @@ -238,9 +249,9 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/node": "^25.9.2", + "@types/node": "^26.0.0", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, }, "js/wasm": { @@ -261,9 +272,9 @@ "@types/audioworklet": "^0.0.100", "@types/bun": "^1.3.14", "@typescript/lib-dom": "npm:@types/web@^0.0.350", - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", }, }, @@ -274,7 +285,7 @@ "@moq/watch": "workspace:*", }, "devDependencies": { - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "playwright": "^1.61.0", "vite": "^8.0.16", }, @@ -297,7 +308,7 @@ "@fails-components/webtransport-transport-http3-quiche", ], "packages": { - "@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/abtesting": ["@algolia/abtesting@1.21.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-Wia5/mNTfiU0PIUN25UMfAGGdASkkwuCS9nBAdmhqrNPY/ff7U/6MgBVdwFDPsa3sA1msutPtO50gvOzx6MOXA=="], "@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=="], @@ -307,99 +318,99 @@ "@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.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-abtesting": ["@algolia/client-abtesting@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-miW8RzAtBgNiEJ9fGEhsOPgWUpekAe64YcVufqXrlykj0Jjmo5nj0a5f/HAzRVX5ZuU1GAVd7BkzFDx7q50P3A=="], - "@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-analytics": ["@algolia/client-analytics@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-eR3J3kB9JX6DdCvDRi3I4KPfwO6fR9HWYRXhVke2TXIoOQafMKCRAneg33JRmIrb+DnnJ/eWApJLF1O1CLPERg=="], - "@algolia/client-common": ["@algolia/client-common@5.48.1", "", {}, "sha512-VXO+qu2Ep6ota28ktvBm3sG53wUHS2n7bgLWmce5jTskdlCD0/JrV4tnBm1l7qpla1CeoQb8D7ShFhad+UoSOw=="], + "@algolia/client-common": ["@algolia/client-common@5.55.1", "", {}, "sha512-P5ak7EurwYqgAiDyb95mgA3WRR/Zu8CPMv36lWTISvL2AmlPyqQPy2nX/KEJRTcwaeTWwrk6wJV4/M93GfjOWw=="], - "@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-insights": ["@algolia/client-insights@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-OVtj9uA//+pjvKQI5INnzbyLrf3ClNv3XRbWswwJ2kHIStQNHtBfHo+LofNB/WhM9xjuXlW5ANn2aMj65UGx7w=="], - "@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-personalization": ["@algolia/client-personalization@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-oKlVFlp+qbIEe4p7E54zSiP2gEV/vDu972Ykv8VDMFwEvreS7m0YKA3a8hGGHwc7yiBUGGiR3LlwzMLfnJmy6Q=="], - "@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-query-suggestions": ["@algolia/client-query-suggestions@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-BOVrld6vdtsFmotVDMTVQfYXwrVplJ+DUvy60JFi+tkWV698q2J9NNPKEO3dr5qxtSLKQP4vHF8n+3U5PDWhOQ=="], - "@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/client-search": ["@algolia/client-search@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-GAqHl9zERhC3bbBfubwUu07G3UXO06gORvOcsiTBZB3et0s3auNUbHlYdYNp4VKa3sUZqH5AcD3OKzU/KDGXjQ=="], - "@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/ingestion": ["@algolia/ingestion@1.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-BXZw+C+gsWL7pZvbnhJUnCXASiDLGcQxVV7h55Pyh2DmSzwdZIVccE5xc9RVD2trtrhIqk5smuODTxtaZqd0IA=="], - "@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/monitoring": ["@algolia/monitoring@1.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-9g/ceZrZTqA62FA3588Xj0onRPjDNfu0pVQqefK0rrHp9H6Wblph/YmzGjZ2g8uqbTh0ZGIvAGCzErU8f7MHpA=="], - "@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/recommend": ["@algolia/recommend@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-cZTIrGyAP+W4A6jDVwvWM/JOaoJKQkD/2a5eLUEeNdKAD45jN7BCpsMDONyhZlosLa4UwL8uiINQzj4iFy9nqg=="], - "@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1" } }, "sha512-MK3wZ2koLDnvH/AmqIF1EKbJlhRS5j74OZGkLpxI4rYvNi9Jn/C7vb5DytBnQ4KUWts7QsmbdwHkxY5txQHXVw=="], + "@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1" } }, "sha512-N6I3leW0UO8Y9Zv90yo2UHgYGuxZO0mjbvzNxDIJDjO0qECEF7Z9XMvSNeUWXQh/iNDA9lr8MfEy3rmZGIcclw=="], - "@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-fetch": ["@algolia/requester-fetch@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1" } }, "sha512-ukU5zeeFs44rQkzv+TRdYard+d+3lmPGs8lPZhHtWE8rfz+LlBSF6s9kP3VQ7LeOYL8Dz0u6tZfnyTrqrumbHQ=="], - "@algolia/requester-node-http": ["@algolia/requester-node-http@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1" } }, "sha512-xcaCqbhupVWhuBP1nwbk1XNvwrGljozutEiLx06mvqDf3o8cHyEgQSHS4fKJM+UAggaWVnnFW+Nne5aQ8SUJXg=="], + "@algolia/requester-node-http": ["@algolia/requester-node-http@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1" } }, "sha512-lCwXyijwPm3vbYHpBXPRomMcD6mgiptmps27gnMCf4HK+u/AOeFPBnIFh4V3l4A5SnP9VRiKBZqwGBpUH0vaTg=="], - "@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/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@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/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@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-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@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/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "@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-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - "@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-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - "@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-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - "@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/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@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/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@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/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], - "@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.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@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.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], - "@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.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], + "@biomejs/biome": ["@biomejs/biome@2.5.1", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.1", "@biomejs/cli-darwin-x64": "2.5.1", "@biomejs/cli-linux-arm64": "2.5.1", "@biomejs/cli-linux-arm64-musl": "2.5.1", "@biomejs/cli-linux-x64": "2.5.1", "@biomejs/cli-linux-x64-musl": "2.5.1", "@biomejs/cli-win32-arm64": "2.5.1", "@biomejs/cli-win32-x64": "2.5.1" }, "bin": { "biome": "bin/biome" } }, "sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg=="], "@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.20260609.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-AK8tYLQm+8BqQMzjZ55ZfuhfIm1eCkj+Ykxz6kWXojdACwjjU03MrwdM9fBDdgzU3upXOs4e1scOFHySlfVQjA=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260625.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-naCfBv0WnnTQIQPTniqMoUlklOIFjrAcSn1X+IAOhY8aFLF/xGYtFjs1eEE8sFib3ZuChGGpU23FFORVczqr0A=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260609.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4kKXfr7ZHU6xQ/R9ShdSuj1A1bEouoRcHzUWdjnuMPBlRsAAVanlxAVYISotFUulLEinayOpRFbhpsfwzrpSSw=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260625.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jmH6zjp6Wrux46+qtFwDwrj+vd7s5bdwEqeGvdnwE0a4IEeAhKs0L42HQOyID+g5lkrHq9m55+AbhtmRAm63Pw=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260609.1", "", { "os": "linux", "cpu": "x64" }, "sha512-T2Ebir2OPHAvvZ0HUh5mi1lN8q30sVi4lf7LIpc28AHoWtoOmJ0jA5AJK4IYJm1MKEbBldq+QsckaHOCQFmRpQ=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260625.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MiQkpA/dX8d83Zp64pzHUKfd6ca4cvwxnNobSP6CnXvfESvnNI9pfa+nfwnParla36sPmnYntNkjR7NjRuDeKQ=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260609.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-INfcYoSsKqEIvPL69/3RkqYoP8WUR0VEN6loWN/3tekXLoJrVOj3E5NjIetsdS8MJN6zc3st/ae4bMuWRRzoDg=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260625.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LxxW7Qv60Xvv37+w6gUSDpYZziyqMy+cZWd9IvSA5ehVgKAxmzEaYPMiSZlxk32nbIWL9u/tfjXYCOKJ4Lo+XQ=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260609.1", "", { "os": "win32", "cpu": "x64" }, "sha512-EWhfxKI1aqUr7S8xuGxgmRCumEzB8iSsCIz6oEqJN+3pZuW3EWiKDGFW4EY1BmwNINLW1eO5VMGYb8Fj6FVYxA=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260625.1", "", { "os": "win32", "cpu": "x64" }, "sha512-LH6iIX1HHaTwVKV5VokDxxUErXJzQoNZFRwVm7Vx/3fB/ApcTcRCUaMqcxI4as94jEUqg+pmX5czOndiveohow=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -409,75 +420,75 @@ "@docsearch/react": ["@docsearch/react@3.8.2", "", { "dependencies": { "@algolia/autocomplete-core": "1.17.7", "@algolia/autocomplete-preset-algolia": "1.17.7", "@docsearch/css": "3.8.2", "algoliasearch": "^5.14.2" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 19.0.0", "react": ">= 16.8.0 < 19.0.0", "react-dom": ">= 16.8.0 < 19.0.0", "search-insights": ">= 1 < 3" }, "optionalPeers": ["@types/react", "react", "react-dom", "search-insights"] }, "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - "@fails-components/webtransport": ["@fails-components/webtransport@1.6.3", "", { "dependencies": { "@types/debug": "^4.1.7", "bindings": "^1.5.0", "debug": "^4.3.4" } }, "sha512-eGeaUt1IMeDyHv1t5ocJg0e31e6w+jGDfuMZW+jNjztYkma5HHUIckmImTYwVXRIKepXe8IpQPYiFfGMCcOubA=="], + "@fails-components/webtransport": ["@fails-components/webtransport@1.6.4", "", { "dependencies": { "@types/debug": "^4.1.7", "bindings": "^1.5.0", "debug": "^4.3.4" } }, "sha512-bDaJo4Ig/irjdZSGlbZr7y8MgZ885qn62iEcNxXBbaIKQTPPS124MaZdV/BisdiwpSdnjQTAuh8NN4+cnlfLhA=="], - "@fails-components/webtransport-transport-http3-quiche": ["@fails-components/webtransport-transport-http3-quiche@1.6.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-CRolXTxtwoMGHk/o4dsW1OlVgEfkjN9DFpNEqaYABuKegUYw41MAcS9Fvcr4mkYEvFjGeYKXB/Id7rmwQErHZw=="], + "@fails-components/webtransport-transport-http3-quiche": ["@fails-components/webtransport-transport-http3-quiche@1.6.4", "", { "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-kURQHNCwAw2JAv3bE2Ch9vtrYVcViBCg2g7qp0UoHQSPT4j1iRCwGv1Bs7PyrMQn/FW3IInqhZOtH191OXt7hg=="], "@hexagon/base64": ["@hexagon/base64@2.0.4", "", {}, "sha512-H/ZY6rGyaEuk0mwQgZ3BVi9hMjFTYpBNFbmtOuec/pPibuGhCMXd8fGtwBaO0h44FkWMurysMsDrpkJsBRmoWQ=="], - "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.70", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-CYNRCgN6nBTjN4dNkrBCjHXNR2e4hQihdsZUs/afUNFOWLSYjfihca4EFN05rRvDk4Xoy2n8tym6IxBZmcn+Qg=="], + "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.87", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-8YciStObhSji3OZFmWAWK6kBujyqO5bLCxeDwLxf3CR3F4PVelq7keC2LBvgTqviWzSTysj5/g4PCFLiAMVGsw=="], "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], - "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + "@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=="], @@ -557,6 +568,8 @@ "@moq/demo-boy": ["@moq/demo-boy@workspace:demo/boy"], + "@moq/flate": ["@moq/flate@workspace:js/flate"], + "@moq/hang": ["@moq/hang@workspace:js/hang"], "@moq/json": ["@moq/json@workspace:js/json"], @@ -583,19 +596,19 @@ "@moq/watch": ["@moq/watch@workspace:js/watch"], - "@moq/web-transport": ["@moq/web-transport@0.1.2", "", { "optionalDependencies": { "@moq/web-transport-darwin-arm64": "0.1.2", "@moq/web-transport-darwin-x64": "0.1.2", "@moq/web-transport-linux-arm64-gnu": "0.1.2", "@moq/web-transport-linux-x64-gnu": "0.1.2", "@moq/web-transport-win32-x64-msvc": "0.1.2" } }, "sha512-cJE0B+T5a8ey1ZgFUIBTWkIWfhCAYGAUgAw8XYeOPsKPjFhtW9M/C+XV8Yu0IeUS4nnrbBPw4sEc+TvMybV4IQ=="], + "@moq/web-transport": ["@moq/web-transport@0.1.3", "", { "optionalDependencies": { "@moq/web-transport-darwin-arm64": "0.1.3", "@moq/web-transport-darwin-x64": "0.1.3", "@moq/web-transport-linux-arm64-gnu": "0.1.3", "@moq/web-transport-linux-x64-gnu": "0.1.3", "@moq/web-transport-win32-x64-msvc": "0.1.3" } }, "sha512-hg3mKWUJaEwtJDGf8Ck62XP8ya+7wyMV/9ycV1A+M3iVly3xBOTPHpT8BrpOLomsBIB9iXQY6Z85MjknhUJW8w=="], - "@moq/web-transport-darwin-arm64": ["@moq/web-transport-darwin-arm64@0.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-waCy1fIyUg0WDwP+rI5Jg7Mw5efsJjImGZzyIDvCXM98O/nWoybPmh8Cka6VP+8xUBeclRpWmlC6phYXtYqPyw=="], + "@moq/web-transport-darwin-arm64": ["@moq/web-transport-darwin-arm64@0.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MSKd2lmgjABS6A4CoaYA2m5XibJFUi3hzz/5TTrWAiRHU8t6WV5iHcPKLMblgXi+QSZ7iLlEL0tXzmN9u0qkJw=="], - "@moq/web-transport-darwin-x64": ["@moq/web-transport-darwin-x64@0.1.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-MWllJfxoZ5ncFxuWpcrwIEMpNjvixt7ycCZQDoW9yjfqsMzfPVRaunvJ8kW0zp0IXrGpC/RFeecFSaE7PmzHpg=="], + "@moq/web-transport-darwin-x64": ["@moq/web-transport-darwin-x64@0.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Kml/EUk8EWiKNkBRcLR8JEpMOqxEpDEHHaDlLd+ro84BY/MAgiJGiVKebGZI6QP2C2OalfAqZZQ0Eh8bB6w62Q=="], - "@moq/web-transport-linux-arm64-gnu": ["@moq/web-transport-linux-arm64-gnu@0.1.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-cjVoLj/SGjetlJx3tbVt0fhzYIN195L9JQ/UkZqpvkhIfWEopPX7MzCWUPRK72mzH7kCyWr0dUGSu7B3pD1j1w=="], + "@moq/web-transport-linux-arm64-gnu": ["@moq/web-transport-linux-arm64-gnu@0.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3/Uko9N3X1b68hTgKVrLwpbMgtbvert0ADvIoziE1wczMcoORsys6dE9APH1jeZaXYgLAN5sVf7fdSMFI6HAQ=="], - "@moq/web-transport-linux-x64-gnu": ["@moq/web-transport-linux-x64-gnu@0.1.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ExZbJFdUeL2G0FkBY7mw/tZTq6+lZfYgIWkjDRa4YkifcGjMevEgpsby5QXlxhs+Og1y5q/x2Xgzd/bAG3TkRQ=="], + "@moq/web-transport-linux-x64-gnu": ["@moq/web-transport-linux-x64-gnu@0.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-WVFRgrhrQUWEKM4uBgBwfz1vGmGOU7DtM38VqlW1+6+bEm6+a0jMJ/6HUBz1bEdzzKZsVcVoT66cZoymyvzjHA=="], - "@moq/web-transport-win32-x64-msvc": ["@moq/web-transport-win32-x64-msvc@0.1.2", "", { "os": "win32", "cpu": "x64" }, "sha512-iypZ2WVoqEDB4dt7qG/wRj3zWAEqpoPP1Lj44L3o23g60UGuqsrGgbpy85qmejBkjMmDrWHr0Phx6pYJNAj/TA=="], + "@moq/web-transport-win32-x64-msvc": ["@moq/web-transport-win32-x64-msvc@0.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yPWBiujSW4bOByb1kfVyBd28MWoLpKneqel4k5KqW941Ofc6H/czegvtmDiFswx1KH531EjKljmuyilH+2g8Rw=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], "@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=="], @@ -615,7 +628,7 @@ "@npmcli/promise-spawn": ["@npmcli/promise-spawn@7.0.2", "", { "dependencies": { "which": "^4.0.0" } }, "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ=="], - "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -625,91 +638,91 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], - "@publint/pack": ["@publint/pack@0.1.4", "", {}, "sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ=="], + "@publint/pack": ["@publint/pack@0.1.5", "", { "dependencies": { "tinyexec": "^1.2.4" } }, "sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.3", "", { "os": "none", "cpu": "arm64" }, "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.3", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@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.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], - "@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-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], - "@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-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], - "@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-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], - "@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-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], - "@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-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], - "@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-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], - "@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-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], - "@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-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], - "@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-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], - "@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-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], - "@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-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], - "@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-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], "@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=="], @@ -729,45 +742,45 @@ "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], - "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], + "@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="], "@svta/cml-iso-bmff": ["@svta/cml-iso-bmff@1.0.2", "", { "peerDependencies": { "@svta/cml-utils": "1.5.0" } }, "sha512-c9UgY1z16zgvTEa6fS++9E9zxLuPtLJ4Dw5ebOgEDtwIw+PIhbh3URGXjv30tyDU2QQTXstI6U1q6h/Q4SkxGQ=="], - "@svta/cml-utils": ["@svta/cml-utils@1.4.0", "", {}, "sha512-vNtHtv/z+9I9ysxFwNrgwxic1oceVPr8TpcpV/NA1l8Gy4phynwtOppkCIBB+PmoyKDcqE4lO85g+lfsuSTBBA=="], + "@svta/cml-utils": ["@svta/cml-utils@1.5.0", "", {}, "sha512-JMqclD7Akd+GSJiuaYNUHOP2wNtf/nauKeszlYeivSHfi0Lp3pmSW5PXDvJ2dO4aPmmSOQbF0ztTsW9Vcs2Whw=="], - "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.1", "@tailwindcss/oxide-darwin-arm64": "4.3.1", "@tailwindcss/oxide-darwin-x64": "4.3.1", "@tailwindcss/oxide-freebsd-x64": "4.3.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", "@tailwindcss/oxide-linux-x64-musl": "4.3.1", "@tailwindcss/oxide-wasm32-wasi": "4.3.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.1", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA=="], "@tailwindcss/typography": ["@tailwindcss/typography@0.5.20", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.1", "", { "dependencies": { "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/audioworklet": ["@types/audioworklet@0.0.100", "", {}, "sha512-mWt4Z5CWi0CupDnSYWJgx6dw61XUk9JSRGrM9F/YNrpOoY/0XG+sxP+8rAjQq80H4febOe8PrzGmmN/X3w8LsQ=="], @@ -783,9 +796,9 @@ "@types/concat-stream": ["@types/concat-stream@2.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-3qe4oQAPNwVNwK4C9c8u+VJqv9kez+2MR4qJpoPFfXtgxxif1QbFusvXzK0/Wra2VX07smostI2VMmJNSpZjuQ=="], - "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -803,7 +816,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], @@ -819,35 +832,75 @@ "@typescript/lib-dom": ["@types/web@0.0.350", "", {}, "sha512-XFISASo1wPsKKtI5v1nJ51YuZYkjUE9kduDFpfHCOKEMbHtRLHX0k3V3bpBeQx2L1VvcxTIECHvC3r3Rfmu0YA=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.1-rc", "", { "os": "aix", "cpu": "ppc64" }, "sha512-oqq2ZfEJ7BQuufcC3QBQndZLPNyamYNHLao8lKRBeeSkZKypBqxPSgkzrcFZtbYcIaBvpiyUnQP9MT7DEYHWbw=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.1-rc", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Slc0yTftT2F/uGDmtPst8ijydneL6uZaLEyr2UjahxZpbhTjHFBJ5agXtVz/TL4A+ldxzjzj+E8QtLZlh/5mXw=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.1-rc", "", { "os": "darwin", "cpu": "x64" }, "sha512-h68iFW/LbA1/BsGgSRGFw981/3s1f/rY27YrmeZNuN+ly7dI+fiDduwT9ZT9866x2onoKNRq7PTyxSKyKDzfAQ=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.1-rc", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-DE+ppd8Ix2c6OMuRkKY4PJ4hngMGJ9M95OQUP17p9xL/1IKXof7npIeuusMN/bgL5o5JzMfSGh+N+5scTYRg0Q=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.1-rc", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ST1ozHMw0u+CLOnWkcTyWDMV4Qn9osZ6fd1V1lnKDM1t0hZIp81mdGpdHxyHJjd7jdGrb6Gb/QXcZ1uqZ0t5zw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.1-rc", "", { "os": "linux", "cpu": "arm" }, "sha512-gHmHwT5Naq5CKM8g9bbaGeEpnwQEvWCLn3fwP4K2m61VQdDKkPk0Dhab/OoZ4LV2SrMddmclYXTzpyg23YGt5g=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.1-rc", "", { "os": "linux", "cpu": "arm64" }, "sha512-N46pRihK3t5zD5MUtTQcdmQUqr1WI4U2nxno1gLwOtRSsB4krFkRjPHcQNG7h2DtRkX64rQiReX6WKwg2wprMA=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-G17Sao312rgiPBTh2F4nOpLpa3CcnBSaNhqNghZk2LNhnsp1RaMO5HMq2me21gqu9xLpc6CIgHtOzU6JBgNlfg=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-0FQspOb5UsQ4tQKvWgUO3pS9OIWkP7/8dPRWq+CRazJUeQZ4LBjtYK52jg5iIOrvItrVl2CwvRtrU3/9OihwJg=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.1-rc", "", { "os": "linux", "cpu": "ppc64" }, "sha512-SUmwfVBEv6A2Ld0eWfcvW0FqrgemfQL8jFGOmV1qYxsDqumjE5DekHXqbstgmbE4SHr4rrjHjvmuGCY+kTH/vw=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-rxeqnNnGiYzv/LlPHi/3+4p0ooR1cNJLjRIHXKovtiVmxXGJq6gtw8VSpbHuWPekyFMXgIAoLCZN0SQ51rAALQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.1-rc", "", { "os": "linux", "cpu": "s390x" }, "sha512-RYWCHCiPypxajdRHM2CNK/eM22e4Ex5TTjV2pXf7PTtBowGr0xX8i8kIMknyZS0LX2QfleYHouaoMVsFDSle3g=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.1-rc", "", { "os": "linux", "cpu": "x64" }, "sha512-PfLJSu0JzroDkqw2m4nqflPEcn8yev0m/vHFQlY9EzHorzjR6QG0wL8AJHvnD1e6h1s76AZngJ5u+z1K/D/HKw=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.1-rc", "", { "os": "none", "cpu": "arm64" }, "sha512-FfbPxH3dTfp8yVIaNM7bdWTixXuyxpzoemluqcqMROSIz+ImpCG3Q9HO9Ptzp9/giv+P9YYEnCMSXh61migj2w=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.1-rc", "", { "os": "none", "cpu": "x64" }, "sha512-FzdTfSzhRYb6hlav6K3cI5RVgcvCTvNAu/vc+t7B6AmZkThQ+t/1ntnvT5fnHmY1Az2RIBw7/b+qtCEG61HJTQ=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.1-rc", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-PQGhlxfNig+0YQ9Wwzd0USPBkt6w/ZqkBQWsU7G/0JkTzunJel+jSWwhKw4947pak/m7hGSeYiI04xReDLGZww=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.1-rc", "", { "os": "openbsd", "cpu": "x64" }, "sha512-WJ7NYgO2mHmLUkI/tZ+hl8lFd26QPJqO8ONOHNuYbdsybLvSB6B6sep222JIVrOfPRDGvFinbGGB+l3m1FWRWA=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.1-rc", "", { "os": "sunos", "cpu": "x64" }, "sha512-UYjDeUxd765V9qcwlUPk4pEXyL0i3G76CJm9baK4i99u1pGO1psf3nXDw4MMmElVOPvGbZag99ZR/O59E2OX6w=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.1-rc", "", { "os": "win32", "cpu": "arm64" }, "sha512-KzXzFSXZOm7zvEt2Aw0MsB2LbTL88znAiVqTDNAOHdlEb7brgmUQh/X2wM/8Be+N0fjEqWKl65cBKNwpWEbJiw=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.1-rc", "", { "os": "win32", "cpu": "x64" }, "sha512-98R3+OqDr/r0/PLWEoXu88AE0lGVLNd335Ew8ONgzK1JWkNs4ou/5BGt3Or1ij4iXjH+c7PRL+jFjCbtWze+EA=="], + "@ungap/global-this": ["@ungap/global-this@0.4.4", "", {}, "sha512-mHkm6FvepJECMNthFuIgpAEFmPOk71UyXuIxYfjytvFTnSDBIz7jmViO+LfHI/AjrazWije0PnSP3+/NlwzqtA=="], - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.2", "", {}, "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA=="], "@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=="], - "@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=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw=="], - "@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=="], + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="], - "@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=="], + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.39", "@vue/compiler-dom": "3.5.39", "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg=="], - "@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/compiler-ssr": ["@vue/compiler-ssr@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw=="], - "@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="], + "@vue/devtools-api": ["@vue/devtools-api@7.7.10", "", { "dependencies": { "@vue/devtools-kit": "^7.7.10" } }, "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA=="], - "@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="], + "@vue/devtools-kit": ["@vue/devtools-kit@7.7.10", "", { "dependencies": { "@vue/devtools-shared": "^7.7.10", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw=="], - "@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="], + "@vue/devtools-shared": ["@vue/devtools-shared@7.7.10", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ=="], - "@vue/reactivity": ["@vue/reactivity@3.5.28", "", { "dependencies": { "@vue/shared": "3.5.28" } }, "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw=="], + "@vue/reactivity": ["@vue/reactivity@3.5.39", "", { "dependencies": { "@vue/shared": "3.5.39" } }, "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog=="], - "@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-core": ["@vue/runtime-core@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw=="], - "@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/runtime-dom": ["@vue/runtime-dom@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/runtime-core": "3.5.39", "@vue/shared": "3.5.39", "csstype": "^3.2.3" } }, "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww=="], - "@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/server-renderer": ["@vue/server-renderer@3.5.39", "", { "dependencies": { "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "vue": "3.5.39" } }, "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw=="], - "@vue/shared": ["@vue/shared@3.5.28", "", {}, "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ=="], + "@vue/shared": ["@vue/shared@3.5.39", "", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="], "@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=="], @@ -859,9 +912,9 @@ "abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], - "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=="], + "algoliasearch": ["algoliasearch@5.55.1", "", { "dependencies": { "@algolia/abtesting": "1.21.1", "@algolia/client-abtesting": "5.55.1", "@algolia/client-analytics": "5.55.1", "@algolia/client-common": "5.55.1", "@algolia/client-insights": "5.55.1", "@algolia/client-personalization": "5.55.1", "@algolia/client-query-suggestions": "5.55.1", "@algolia/client-search": "5.55.1", "@algolia/ingestion": "1.55.1", "@algolia/monitoring": "1.55.1", "@algolia/recommend": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-FyaFnnsbVPtevQwqSj/SdxE3jAsSsY0BEH8IVLf9rXxEBdAhAmT6VKCVSMWoaPIHVN1Eufh/1w8q6k8URpIkWw=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -873,17 +926,17 @@ "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], - "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.3", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w=="], + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="], - "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=="], + "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "balanced-match": ["balanced-match@4.0.2", "", { "dependencies": { "jackspeak": "^4.2.3" } }, "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -897,11 +950,11 @@ "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - "brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], @@ -913,7 +966,7 @@ "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.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -955,7 +1008,7 @@ "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@10.0.3", "", { "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", "shell-quote": "1.8.4", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" }, "bin": { "concurrently": "dist/bin/index.js", "conc": "dist/bin/index.js" } }, "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA=="], + "concurrently": ["concurrently@10.0.3", "", { "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", "shell-quote": "1.8.4", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" }, "bin": { "conc": "dist/bin/index.js", "concurrently": "dist/bin/index.js" } }, "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA=="], "connect-history-api-fallback": ["connect-history-api-fallback@1.6.0", "", {}, "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg=="], @@ -1015,7 +1068,7 @@ "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.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], + "electron-to-chromium": ["electron-to-chromium@1.5.380", "", {}, "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -1023,7 +1076,7 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="], + "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -1033,7 +1086,7 @@ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], - "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -1057,7 +1110,7 @@ "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - "filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="], + "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -1081,7 +1134,7 @@ "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], - "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=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1137,6 +1190,8 @@ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], @@ -1153,7 +1208,7 @@ "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=="], - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -1165,7 +1220,7 @@ "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=="], + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -1201,7 +1256,7 @@ "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], - "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1287,13 +1342,13 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "miniflare": ["miniflare@4.20260609.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.24.8", "workerd": "1.20260609.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-4ZfNh9ACDa/mKKQvTSO2vigyQS2MB7dEU02KRPle4FqL7S6nek+2Fq6WGzazZbt1OORYgb4OGVLnOCx+My2NNA=="], + "miniflare": ["miniflare@4.20260625.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260625.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA=="], - "minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], @@ -1309,21 +1364,21 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], "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-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="], "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-api-headers": ["node-api-headers@1.9.0", "", {}, "sha512-2oNILP4jXwRB4ywnYKjVk1YyJ96n2D4EOVJO6S3oYZ5PtbJrw3Yt9TpAuX3nBLMuzn74rnfGQrv13pS9vC+YiA=="], "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-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], "nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -1345,7 +1400,7 @@ "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=="], - "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -1365,7 +1420,7 @@ "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-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], @@ -1377,17 +1432,19 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "playwright": ["playwright@1.61.0", "", { "dependencies": { "playwright-core": "1.61.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], - "playwright-core": ["playwright-core@1.61.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA=="], + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], - "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], - "preact": ["preact@10.28.3", "", {}, "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + + "preact": ["preact@10.29.3", "", {}, "sha512-D9NL1GAnJZhc3RndVs4gDdxEeU9TcHgywMrhhOsnpdlvFjdbx0gAsLUnH6JEhlJH5giL7Tx5biWPUSEXE/HPzw=="], "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=="], @@ -1397,11 +1454,11 @@ "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=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "publint": ["publint@0.3.21", "", { "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-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ=="], - "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1505,9 +1562,9 @@ "rimraf": ["rimraf@6.1.3", "", { "dependencies": { "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA=="], - "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + "rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="], - "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=="], + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], @@ -1523,9 +1580,9 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], + "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], - "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], + "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], "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=="], @@ -1585,22 +1642,24 @@ "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], + "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], - "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], + "tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "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=="], + "tar": ["tar@7.5.19", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="], - "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=="], + "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="], "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=="], - "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=="], + "terser": ["terser@5.48.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-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="], "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1621,11 +1680,11 @@ "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "typescript": ["typescript@7.0.1-rc", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.1-rc", "@typescript/typescript-darwin-arm64": "7.0.1-rc", "@typescript/typescript-darwin-x64": "7.0.1-rc", "@typescript/typescript-freebsd-arm64": "7.0.1-rc", "@typescript/typescript-freebsd-x64": "7.0.1-rc", "@typescript/typescript-linux-arm": "7.0.1-rc", "@typescript/typescript-linux-arm64": "7.0.1-rc", "@typescript/typescript-linux-loong64": "7.0.1-rc", "@typescript/typescript-linux-mips64el": "7.0.1-rc", "@typescript/typescript-linux-ppc64": "7.0.1-rc", "@typescript/typescript-linux-riscv64": "7.0.1-rc", "@typescript/typescript-linux-s390x": "7.0.1-rc", "@typescript/typescript-linux-x64": "7.0.1-rc", "@typescript/typescript-netbsd-arm64": "7.0.1-rc", "@typescript/typescript-netbsd-x64": "7.0.1-rc", "@typescript/typescript-openbsd-arm64": "7.0.1-rc", "@typescript/typescript-openbsd-x64": "7.0.1-rc", "@typescript/typescript-sunos-x64": "7.0.1-rc", "@typescript/typescript-win32-arm64": "7.0.1-rc", "@typescript/typescript-win32-x64": "7.0.1-rc" }, "bin": { "tsc": "bin/tsc" } }, "sha512-drEP77wK7CCDlPfXZH4e008UUQOsw1DFmHmZOZjuNA+yoDLLnSNMZRXi90NbV/1LVo7SbNLq1bs3jjvk49TEqQ=="], - "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], - "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], @@ -1675,25 +1734,25 @@ "vfile-statistics": ["vfile-statistics@3.0.0", "", { "dependencies": { "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-/qlwqwWBWFOmpXujL/20P+Iuydil0rZZNglR+VNm6J0gpLHwuVM5s7g2TfVoswbXjZ4HuIhLMySEyIw5i7/D8w=="], - "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + "vite": ["vite@8.1.0", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q=="], "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=="], "vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], - "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], "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=="], - "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.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/compiler-sfc": "3.5.39", "@vue/runtime-dom": "3.5.39", "@vue/server-renderer": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA=="], "walk-up-path": ["walk-up-path@3.0.1", "", {}, "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA=="], "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - "workerd": ["workerd@1.20260609.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260609.1", "@cloudflare/workerd-darwin-arm64": "1.20260609.1", "@cloudflare/workerd-linux-64": "1.20260609.1", "@cloudflare/workerd-linux-arm64": "1.20260609.1", "@cloudflare/workerd-windows-64": "1.20260609.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-KF/Y/8f4VoXCk87NuU6RqmO0X5fdzcrxU3XzAgoPUpnH9t1ZyzRgX1O/9sJvjItxroCBTEBzKssda02Dz9i6BA=="], + "workerd": ["workerd@1.20260625.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260625.1", "@cloudflare/workerd-darwin-arm64": "1.20260625.1", "@cloudflare/workerd-linux-64": "1.20260625.1", "@cloudflare/workerd-linux-arm64": "1.20260625.1", "@cloudflare/workerd-windows-64": "1.20260625.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-GApQvFX52SDM6L4u0+RRnUDB1wJOnEwoXjinkmOPtIyofWBxrlZckdegJSYc1leg++lLZ3+DQ4zMVmBqYVtzfA=="], - "wrangler": ["wrangler@4.99.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.20260609.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260609.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260609.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-i7GA2mZETTyq3ljWdEzM908FjLaMWZ1AaAHKaOJ8pFA/tonf2VqIWDyBGzKleIVBbNQxOTIY2wnbv0iaK3rC6g=="], + "wrangler": ["wrangler@4.105.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260625.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260625.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260625.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-7dXFH6OLj1Fv0y6ZeRPUxFTkp+duWD7/xxVi/1c0vfOeEYwIFKWB7cdqnY05DvY1Ta3BnqAwRkXfLs8PDj538g=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -1701,15 +1760,15 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "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=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "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=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], @@ -1727,21 +1786,19 @@ "@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=="], - "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], - "@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=="], "@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=="], "@npmcli/config/ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], - "@npmcli/config/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@npmcli/config/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "@npmcli/git/ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], "@npmcli/git/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@npmcli/git/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@npmcli/git/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "@npmcli/git/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], @@ -1751,43 +1808,37 @@ "@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=="], - "@npmcli/package-json/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@npmcli/package-json/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "@npmcli/promise-spawn/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - "@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@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/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@types/concat-stream/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], + "@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=="], "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "@vue/compiler-sfc/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - - "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "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=="], - "balanced-match/jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], - - "bun-types/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], - - "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=="], + "cmake-js/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], - "cmake-js/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "cmake-js/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "cmake-js/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=="], + "cmake-js/yargs": ["yargs@17.7.3", "", { "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-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], "copy-anything/is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], @@ -1795,31 +1846,31 @@ "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], - "filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "html-minifier-terser/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "node-abi/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "normalize-package-data/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "normalize-package-data/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "npm-install-checks/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "npm-install-checks/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "npm-package-arg/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "npm-package-arg/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "npm-pick-manifest/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "npm-pick-manifest/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1833,7 +1884,7 @@ "unenv/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "unified-engine/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "unified-engine/@types/node": ["@types/node@22.20.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g=="], "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=="], @@ -1843,8 +1894,6 @@ "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@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=="], - "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi-cjs/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=="], @@ -1859,7 +1908,7 @@ "@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=="], - "@npmcli/map-workspaces/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@npmcli/map-workspaces/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "@npmcli/package-json/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -1867,13 +1916,7 @@ "@npmcli/promise-spawn/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - "@types/concat-stream/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@vue/compiler-sfc/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "balanced-match/jackspeak/@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], - - "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@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=="], "cmake-js/yargs/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=="], @@ -1883,7 +1926,7 @@ "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "filelist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1895,71 +1938,63 @@ "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=="], - "vitepress/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - - "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - - "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - - "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - - "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + "@npmcli/map-workspaces/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + "@npmcli/package-json/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], - "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + "@npmcli/package-json/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], - "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], - "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + "@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=="], - "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], - "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], - "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], - "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], - "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + "@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=="], - "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], - "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], - "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], - "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], - "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], - "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + "@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=="], - "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], - "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], - "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], - "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], - "@npmcli/map-workspaces/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], - "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], - "@npmcli/package-json/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], - "@npmcli/package-json/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], "cmake-js/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1971,7 +2006,7 @@ "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "unified-engine/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "unified-engine/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "unified-engine/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -2021,8 +2056,6 @@ "vitepress/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], - "vitepress/vite/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "@npmcli/package-json/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "cmake-js/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/demo/boy/package.json b/demo/boy/package.json index ee9ee247d..5bed6ebb6 100644 --- a/demo/boy/package.json +++ b/demo/boy/package.json @@ -12,8 +12,8 @@ "@moq/boy": "workspace:^" }, "devDependencies": { - "esbuild": "^0.28.0", - "typescript": "^6.0.3", + "esbuild": "^0.28.1", + "typescript": "7.0.1-rc", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12" } diff --git a/demo/pub/justfile b/demo/pub/justfile index 20556733c..3b2541b85 100644 --- a/demo/pub/justfile +++ b/demo/pub/justfile @@ -142,8 +142,8 @@ hls name relay="http://localhost:4443": } 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 + echo ">>> Importing HLS via moq-cli" + cargo run --bin moq-cli -- hls --url "{{ relay }}" import --broadcast "{{ name }}.hang" --playlist "$OUT_DIR/master.m3u8" EXIT_CODE=$? cleanup diff --git a/demo/web/package.json b/demo/web/package.json index 1725edeb2..bd8d36adf 100644 --- a/demo/web/package.json +++ b/demo/web/package.json @@ -19,14 +19,14 @@ "//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.20", - "@tailwindcss/vite": "^4.3.0", - "esbuild": "^0.28.0", + "@tailwindcss/vite": "^4.3.1", + "esbuild": "^0.28.1", "highlight.js": "^11.11.1", - "open": "^10.2.0", + "open": "^11.0.0", "solid-element": "^1.9.1", "solid-js": "^1.9.13", "tailwindcss": "^4.1.13", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12" } diff --git a/demo/web/vite.config.ts b/demo/web/vite.config.ts index da1fb25e5..051b48fdf 100644 --- a/demo/web/vite.config.ts +++ b/demo/web/vite.config.ts @@ -14,8 +14,9 @@ export default defineConfig({ solidPlugin(), workletInline(), consoleOverlay(), - // Open the watch, publish, and stats demos each in their own tab. - openTabs(["watch.html", "publish.html", "stats.html"]), + // Open the stats, publish, and watch demos each in their own tab. + // Order matters: the browser focuses the last tab, so watch ends up in front. + openTabs(["stats.html", "publish.html", "watch.html"]), ], build: { target: "esnext", diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index f7b6b564d..66c7b07e6 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -137,6 +137,8 @@ export default defineConfig({ ], }, { text: "CLI", link: "/bin/cli" }, + { text: "WebRTC", link: "/bin/rtc" }, + { text: "RTMP", link: "/bin/rtmp" }, { text: "OBS", link: "/bin/obs" }, { text: "GStreamer", link: "/bin/gstreamer" }, { text: "HLS", link: "/bin/hls" }, diff --git a/doc/bin/cli.md b/doc/bin/cli.md index ded858b8e..079bc1869 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -190,7 +190,6 @@ Publish (read from stdin unless noted): - `fmp4` - fragmented MP4 / CMAF - `ts` - MPEG-TS (H.264 / H.265 video; AAC, MP2, AC-3, or E-AC-3 audio) - `flv` - FLV / RTMP (H.264 video, AAC audio) -- `hls --playlist ` - HLS playlist ingest - `capture` - capture local devices directly (camera H.264 + microphone Opus; requires the `capture` build feature; does not read stdin) Subscribe (`--format`): @@ -200,6 +199,30 @@ Subscribe (`--format`): - `ts` - MPEG-TS - `flv` - FLV / RTMP (H.264 video, AAC audio) +`subscribe` also takes `--catalog` to pick which catalog track to read for track +discovery. When omitted, it's auto-detected from the broadcast name suffix +(`.hang` -> `hang`, `.msf` -> `msf`), falling back to `hang`: + +- `hang` - the `catalog.json` JSON catalog (default) +- `hangz` - the DEFLATE-compressed `catalog.json.z` catalog (opt-in; shares the `.hang` suffix and is never auto-detected) +- `msf` - the MSF `catalog` track + +### HLS / LL-HLS + +Import an HLS master/media playlist into a MoQ broadcast: + +```bash +moq-cli hls --url https://relay.example.com/anon import \ + --broadcast my-stream.hang \ + --playlist https://example.com/live/master.m3u8 +``` + +Serve MoQ broadcasts as HLS / LL-HLS over HTTP: + +```bash +moq-cli hls --url https://relay.example.com/anon export --listen '[::]:8089' +``` + ### MPEG-TS Ingest an MPEG-TS stream from FFmpeg and play one back out: @@ -228,7 +251,7 @@ Elementary streams the CLI does not decode (SCTE-35 cues, teletext, DVB subtitles, private data, ...) are carried verbatim too, one MoQ track per PID, described in the catalog `mpegts` section. They survive `publish ts | relay | subscribe --format ts` end-to-end with their original PIDs, PMT descriptors, and -PES stream_ids, so a contribution feed keeps its ancillary streams. The relay +PES stream\_ids, so a contribution feed keeps its ancillary streams. The relay forwards them transparently and never parses the payload. ### FLV diff --git a/doc/bin/index.md b/doc/bin/index.md index 021448115..cab524d63 100644 --- a/doc/bin/index.md +++ b/doc/bin/index.md @@ -31,9 +31,9 @@ ffmpeg -f avfoundation -i "0" -f mpegts - | moq-cli publish --url https://relay. ## [moq-rtc](/bin/rtc) -A WebRTC <-> MoQ gateway. Accepts WHIP from any conformant publisher -(OBS, browsers) and republishes to a MoQ relay. -WHEP egress is in progress. +A WebRTC <-> MoQ gateway. Speaks WHIP (publish) and WHEP (subscribe) in either +HTTP role, so it can accept incoming peers (OBS, browsers) or dial out to a +remote WebRTC server. Ingest and egress both work for H.264, VP8, VP9, and Opus. ## [moq-hls](/bin/hls) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index eb95b42eb..198925d98 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -171,6 +171,17 @@ auth_api = "https://api.moq.dev/cluster/auth" Unlike the standalone flags, the unified call **fails closed**: any network error, non-2xx status, or unparseable response rejects the connection. The verifying key itself comes from this call, so there is no safe fallback; the endpoint's `Cache-Control` softens transient failures. This applies to mTLS peers as well, including root (`/`) connections such as cluster peers: when an auth API is configured it is the source of truth for every connection (so it can alias and tier the root too), and a failed lookup rejects the connection so the peer reconnects and self-heals once the API recovers. The only fail-open case is when **no** auth API is configured, where the client certificate is the sole credential and the path is used unchanged. +### Authenticating the relay to the auth API + +The outbound HTTP the relay makes for auth (`--auth-api` requests and JWK fetches) reuses the cluster client's TLS configuration. The same `--client-tls-cert` / `--client-tls-key` the relay presents when dialing cluster peers also identifies it to the auth API, and `--client-tls-root` trusts a private CA on the endpoint (env `MOQ_CLIENT_TLS_*`, or `[client.tls]` in TOML). So an auth API can require mTLS and recognize the relay by the same certificate it uses for clustering. + +```toml +[client.tls] +cert = "/etc/moq/relay-client.pem" +key = "/etc/moq/relay-client.key" +root = ["/etc/moq/auth-api-ca.pem"] +``` + ## Supported Algorithms ### Symmetric (HMAC) @@ -222,7 +233,7 @@ Client certificate presentation is **optional**: connections without a certificate fall through to the normal JWT path unchanged. ```toml -[tls] +[server.tls] cert = ["/etc/moq/server.pem"] key = ["/etc/moq/server.key"] # One or more PEM files containing the CAs trusted to sign peer certificates. diff --git a/doc/bin/relay/index.md b/doc/bin/relay/index.md index 5a9deb11e..6bb07c540 100644 --- a/doc/bin/relay/index.md +++ b/doc/bin/relay/index.md @@ -70,7 +70,7 @@ Create a `relay.toml` configuration file: [server] bind = "[::]:4443" # Listen on all interfaces, port 4443 -[tls] +[server.tls] cert = "/path/to/cert.pem" # TLS certificate key = "/path/to/key.pem" # TLS private key @@ -109,7 +109,7 @@ sudo certbot certonly --standalone -d relay.example.com Update `relay.toml`: ```toml -[tls] +[server.tls] cert = "/etc/letsencrypt/live/relay.example.com/fullchain.pem" key = "/etc/letsencrypt/live/relay.example.com/privkey.pem" ``` diff --git a/doc/bin/rtc.md b/doc/bin/rtc.md index 64381c928..252f6479d 100644 --- a/doc/bin/rtc.md +++ b/doc/bin/rtc.md @@ -118,5 +118,3 @@ On ingest, H.264 is reassembled by str0m as Annex-B; `moq-mux`'s H.264 importer in `Avc3` mode publishes the inline-parameter shape directly, which lines up with what the WebCodecs decoder in `@moq/watch` already expects. No extra conversion needed in the gateway. - -(Written by Claude) diff --git a/doc/concept/layer/hang.md b/doc/concept/layer/hang.md index 45d7cc592..8eda4974b 100644 --- a/doc/concept/layer/hang.md +++ b/doc/concept/layer/hang.md @@ -46,6 +46,15 @@ Here is Big Buck Bunny's `catalog.json` as of 2026-02-02: } ``` +### Compression + +The catalog is published on two tracks with identical content: `catalog.json` (plain JSON) and `catalog.json.z` (the same JSON, DEFLATE-compressed per group). +A publisher always serves both; a consumer reads whichever it prefers and defaults to the uncompressed `catalog.json`. + +The compression is the group-scoped `deflate-raw` ([RFC 1951](https://www.rfc-editor.org/rfc/rfc1951.html)) stream used by `@moq/json` / `moq-json`, interoperable between the browser and native. +To read the compressed track, opt in explicitly: pass `--catalog hangz` to `moq-cli subscribe`, `CatalogFormat::HangZ` in Rust, or `catalogFormat: "hangz"` to `@moq/watch`. +The `.hang` broadcast suffix is unchanged: the compressed track is an extra track on the same broadcast, not a different broadcast name. + ### Audio [See the latest schema](https://github.com/moq-dev/moq/blob/main/js/hang/src/catalog/audio.ts). diff --git a/doc/concept/standard/interop.md b/doc/concept/standard/interop.md index ca734bdd4..fa82e51d2 100644 --- a/doc/concept/standard/interop.md +++ b/doc/concept/standard/interop.md @@ -43,7 +43,7 @@ If it plays, you interop. That's the whole test. - **`SUBSCRIBE_NAMESPACE` is required.** The subscriber discovers broadcasts by sending `SUBSCRIBE_NAMESPACE` and waiting for a matching announce, so your relay must support it. The publisher announces with `PUBLISH_NAMESPACE`. -- **Self-signed or expired cert?** Add `--tls-disable-verify`. +- **Self-signed or expired cert?** Add `--client-tls-disable-verify`. - **Subscriber sees nothing?** If your relay doesn't replay existing announcements, start the subscriber before the publisher. - **Verbose logs:** prefix with `RUST_LOG=info,moq_net=debug`. It prints the diff --git a/doc/index.md b/doc/index.md index c9cfdbde9..27da02ccc 100644 --- a/doc/index.md +++ b/doc/index.md @@ -99,7 +99,7 @@ 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. +- [moq-cli](/bin/cli) - A CLI that can import and publish MoQ broadcasts from a variety of formats (fMP4, HLS, MPEG-TS, FLV, 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. @@ -112,7 +112,7 @@ Integrate MoQ into your application without fear. Focused on [native](/lib/rs/en 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. +- [moq-mux](/lib/rs/crate/moq-mux) - Media muxers/demuxers for fMP4, CMAF, MPEG-TS, and FLV. - [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/) diff --git a/doc/lib/index.md b/doc/lib/index.md index 25410bf6d..6658b0331 100644 --- a/doc/lib/index.md +++ b/doc/lib/index.md @@ -15,7 +15,7 @@ The reference implementation. Used by every server-side tool and by the FFI core - [`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-mux`](/lib/rs/crate/moq-mux) - fMP4/CMAF, MPEG-TS, and FLV import/export - [`moq-native`](/lib/rs/crate/moq-native) - QUIC endpoint helpers - [...and more](/lib/rs/) diff --git a/doc/lib/js/@moq/watch.md b/doc/lib/js/@moq/watch.md index 190a66fc4..2e2678459 100644 --- a/doc/lib/js/@moq/watch.md +++ b/doc/lib/js/@moq/watch.md @@ -73,7 +73,7 @@ a real bundler (the examples below). - `latency`: Latency target. `"real-time"` (default) derives it from RTT, or a number sets a fixed jitter buffer in ms. Collapses `latency-min` and `latency-max` to one value (minimize latency). - `latency-min`: Latency floor (the jitter/startup buffer). Same units as `latency`; leaves the ceiling untouched. - `latency-max`: Latency ceiling. `"real-time"` (default) minimizes latency; a number caps at that many ms. A ceiling above the floor enables [buffered playback](#buffered-playback): build up a buffer from future-dated frames instead of skipping ahead. -- `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-format`: Catalog format. One of `"hang"`, `"hangz"` (the [DEFLATE-compressed](/concept/layer/hang#compression) `catalog.json.z` track), `"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"`. `"hangz"` is opt-in only and never auto-detected (it shares the `.hang` suffix). ## Catalog Formats diff --git a/doc/lib/rs/crate/hang.md b/doc/lib/rs/crate/hang.md index cd266bc01..b783a4047 100644 --- a/doc/lib/rs/crate/hang.md +++ b/doc/lib/rs/crate/hang.md @@ -94,7 +94,7 @@ Each frame in `hang` consists of a timestamp and codec bitstream payload. See th ## CMAF Import -For importing fMP4/CMAF/HLS files, see the [moq-mux](/lib/rs/crate/moq-mux) crate. +For importing fMP4/CMAF files, see the [moq-mux](/lib/rs/crate/moq-mux) crate. For HLS, see [moq-hls](https://github.com/moq-dev/moq/tree/main/rs/moq-hls). ## Grouping @@ -153,7 +153,7 @@ Key types: - `Catalog` - Track metadata - `VideoConfig` / `AudioConfig` - Track configuration - `Frame` - Timestamp + codec bitstream -- [moq-mux](/lib/rs/crate/moq-mux) - CMAF/fMP4/HLS import +- [moq-mux](/lib/rs/crate/moq-mux) - CMAF/fMP4 import ## Protocol Specification diff --git a/doc/lib/rs/crate/index.md b/doc/lib/rs/crate/index.md index acf39fff0..6ee81f317 100644 --- a/doc/lib/rs/crate/index.md +++ b/doc/lib/rs/crate/index.md @@ -14,5 +14,5 @@ Rust crates providing the MoQ protocol implementation and related tooling. | [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) | +| [moq-mux](./moq-mux) | Media muxers/demuxers (fMP4, CMAF, MPEG-TS, FLV) | | [libmoq](./libmoq) | C FFI bindings | diff --git a/doc/lib/rs/crate/moq-mux.md b/doc/lib/rs/crate/moq-mux.md index 501c724d1..d8bee3525 100644 --- a/doc/lib/rs/crate/moq-mux.md +++ b/doc/lib/rs/crate/moq-mux.md @@ -19,7 +19,6 @@ Media muxers and demuxers for converting existing media formats into MoQ broadca - **MPEG-TS** - Transport stream (import and export) - **Matroska / WebM** - EBML container (import and export) - **FLV** - Flash Video / RTMP container (H.264 + AAC; import and export) -- **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. @@ -33,20 +32,11 @@ Add to your `Cargo.toml`: 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 +### Import fMP4 -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. +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 streams. Use [moq-hls](https://github.com/moq-dev/moq/tree/main/rs/moq-hls) for HLS import and export. ## Supported Codecs @@ -69,7 +59,6 @@ See the [moq-cli source](https://github.com/moq-dev/moq/tree/main/rs/moq-cli) fo ## 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 @@ -85,7 +74,6 @@ 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 diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index 3d10f957a..8a0db2393 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -50,7 +50,7 @@ Media muxers and demuxers for importing existing formats into MoQ. **Features:** - fMP4/CMAF import -- HLS playlist import +- MPEG-TS and FLV import/export - H.264/H.265 Annex B parsing - AAC and Opus codec support diff --git a/doc/package.json b/doc/package.json index d7292dc3e..a20bcc9a8 100644 --- a/doc/package.json +++ b/doc/package.json @@ -11,6 +11,6 @@ }, "devDependencies": { "vitepress": "^1.6.4", - "wrangler": "^4.99.0" + "wrangler": "^4.103.0" } } diff --git a/doc/setup/windows.md b/doc/setup/windows.md index 950f68c9a..b00430bcf 100644 --- a/doc/setup/windows.md +++ b/doc/setup/windows.md @@ -62,10 +62,10 @@ REM Grab the relay's certificate fingerprint for /f %f in ('curl -s http://localhost:4443/certificate.sha256') do set FP=%f REM Publish -cargo run -p moq-native --example clock -- --url https://localhost:4443 --broadcast clock --tls-fingerprint %FP% publish +cargo run -p moq-native --example clock -- --url https://localhost:4443 --broadcast clock --client-tls-fingerprint %FP% publish REM Subscribe (separate terminal) -cargo run -p moq-native --example clock -- --url https://localhost:4443 --broadcast clock --tls-fingerprint %FP% subscribe +cargo run -p moq-native --example clock -- --url https://localhost:4443 --broadcast clock --client-tls-fingerprint %FP% subscribe ``` The subscriber prints the current time once per second, sourced from the diff --git a/infra/apt/bun.lock b/infra/apt/bun.lock index eef04294f..e0f842a51 100644 --- a/infra/apt/bun.lock +++ b/infra/apt/bun.lock @@ -5,7 +5,7 @@ "name": "apt-moq-dev", "devDependencies": { "@cloudflare/workers-types": "^4", - "typescript": "^6", + "typescript": "7.0.1-rc", "wrangler": "^4", }, }, @@ -149,6 +149,46 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.1-rc", "", { "os": "aix", "cpu": "ppc64" }, "sha512-oqq2ZfEJ7BQuufcC3QBQndZLPNyamYNHLao8lKRBeeSkZKypBqxPSgkzrcFZtbYcIaBvpiyUnQP9MT7DEYHWbw=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.1-rc", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Slc0yTftT2F/uGDmtPst8ijydneL6uZaLEyr2UjahxZpbhTjHFBJ5agXtVz/TL4A+ldxzjzj+E8QtLZlh/5mXw=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.1-rc", "", { "os": "darwin", "cpu": "x64" }, "sha512-h68iFW/LbA1/BsGgSRGFw981/3s1f/rY27YrmeZNuN+ly7dI+fiDduwT9ZT9866x2onoKNRq7PTyxSKyKDzfAQ=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.1-rc", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-DE+ppd8Ix2c6OMuRkKY4PJ4hngMGJ9M95OQUP17p9xL/1IKXof7npIeuusMN/bgL5o5JzMfSGh+N+5scTYRg0Q=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.1-rc", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ST1ozHMw0u+CLOnWkcTyWDMV4Qn9osZ6fd1V1lnKDM1t0hZIp81mdGpdHxyHJjd7jdGrb6Gb/QXcZ1uqZ0t5zw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.1-rc", "", { "os": "linux", "cpu": "arm" }, "sha512-gHmHwT5Naq5CKM8g9bbaGeEpnwQEvWCLn3fwP4K2m61VQdDKkPk0Dhab/OoZ4LV2SrMddmclYXTzpyg23YGt5g=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.1-rc", "", { "os": "linux", "cpu": "arm64" }, "sha512-N46pRihK3t5zD5MUtTQcdmQUqr1WI4U2nxno1gLwOtRSsB4krFkRjPHcQNG7h2DtRkX64rQiReX6WKwg2wprMA=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-G17Sao312rgiPBTh2F4nOpLpa3CcnBSaNhqNghZk2LNhnsp1RaMO5HMq2me21gqu9xLpc6CIgHtOzU6JBgNlfg=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-0FQspOb5UsQ4tQKvWgUO3pS9OIWkP7/8dPRWq+CRazJUeQZ4LBjtYK52jg5iIOrvItrVl2CwvRtrU3/9OihwJg=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.1-rc", "", { "os": "linux", "cpu": "ppc64" }, "sha512-SUmwfVBEv6A2Ld0eWfcvW0FqrgemfQL8jFGOmV1qYxsDqumjE5DekHXqbstgmbE4SHr4rrjHjvmuGCY+kTH/vw=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-rxeqnNnGiYzv/LlPHi/3+4p0ooR1cNJLjRIHXKovtiVmxXGJq6gtw8VSpbHuWPekyFMXgIAoLCZN0SQ51rAALQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.1-rc", "", { "os": "linux", "cpu": "s390x" }, "sha512-RYWCHCiPypxajdRHM2CNK/eM22e4Ex5TTjV2pXf7PTtBowGr0xX8i8kIMknyZS0LX2QfleYHouaoMVsFDSle3g=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.1-rc", "", { "os": "linux", "cpu": "x64" }, "sha512-PfLJSu0JzroDkqw2m4nqflPEcn8yev0m/vHFQlY9EzHorzjR6QG0wL8AJHvnD1e6h1s76AZngJ5u+z1K/D/HKw=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.1-rc", "", { "os": "none", "cpu": "arm64" }, "sha512-FfbPxH3dTfp8yVIaNM7bdWTixXuyxpzoemluqcqMROSIz+ImpCG3Q9HO9Ptzp9/giv+P9YYEnCMSXh61migj2w=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.1-rc", "", { "os": "none", "cpu": "x64" }, "sha512-FzdTfSzhRYb6hlav6K3cI5RVgcvCTvNAu/vc+t7B6AmZkThQ+t/1ntnvT5fnHmY1Az2RIBw7/b+qtCEG61HJTQ=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.1-rc", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-PQGhlxfNig+0YQ9Wwzd0USPBkt6w/ZqkBQWsU7G/0JkTzunJel+jSWwhKw4947pak/m7hGSeYiI04xReDLGZww=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.1-rc", "", { "os": "openbsd", "cpu": "x64" }, "sha512-WJ7NYgO2mHmLUkI/tZ+hl8lFd26QPJqO8ONOHNuYbdsybLvSB6B6sep222JIVrOfPRDGvFinbGGB+l3m1FWRWA=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.1-rc", "", { "os": "sunos", "cpu": "x64" }, "sha512-UYjDeUxd765V9qcwlUPk4pEXyL0i3G76CJm9baK4i99u1pGO1psf3nXDw4MMmElVOPvGbZag99ZR/O59E2OX6w=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.1-rc", "", { "os": "win32", "cpu": "arm64" }, "sha512-KzXzFSXZOm7zvEt2Aw0MsB2LbTL88znAiVqTDNAOHdlEb7brgmUQh/X2wM/8Be+N0fjEqWKl65cBKNwpWEbJiw=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.1-rc", "", { "os": "win32", "cpu": "x64" }, "sha512-98R3+OqDr/r0/PLWEoXu88AE0lGVLNd335Ew8ONgzK1JWkNs4ou/5BGt3Or1ij4iXjH+c7PRL+jFjCbtWze+EA=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -185,7 +225,7 @@ "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=="], + "typescript": ["typescript@7.0.1-rc", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.1-rc", "@typescript/typescript-darwin-arm64": "7.0.1-rc", "@typescript/typescript-darwin-x64": "7.0.1-rc", "@typescript/typescript-freebsd-arm64": "7.0.1-rc", "@typescript/typescript-freebsd-x64": "7.0.1-rc", "@typescript/typescript-linux-arm": "7.0.1-rc", "@typescript/typescript-linux-arm64": "7.0.1-rc", "@typescript/typescript-linux-loong64": "7.0.1-rc", "@typescript/typescript-linux-mips64el": "7.0.1-rc", "@typescript/typescript-linux-ppc64": "7.0.1-rc", "@typescript/typescript-linux-riscv64": "7.0.1-rc", "@typescript/typescript-linux-s390x": "7.0.1-rc", "@typescript/typescript-linux-x64": "7.0.1-rc", "@typescript/typescript-netbsd-arm64": "7.0.1-rc", "@typescript/typescript-netbsd-x64": "7.0.1-rc", "@typescript/typescript-openbsd-arm64": "7.0.1-rc", "@typescript/typescript-openbsd-x64": "7.0.1-rc", "@typescript/typescript-sunos-x64": "7.0.1-rc", "@typescript/typescript-win32-arm64": "7.0.1-rc", "@typescript/typescript-win32-x64": "7.0.1-rc" }, "bin": { "tsc": "bin/tsc" } }, "sha512-drEP77wK7CCDlPfXZH4e008UUQOsw1DFmHmZOZjuNA+yoDLLnSNMZRXi90NbV/1LVo7SbNLq1bs3jjvk49TEqQ=="], "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], diff --git a/infra/apt/package.json b/infra/apt/package.json index d83e56908..8bbd60b22 100644 --- a/infra/apt/package.json +++ b/infra/apt/package.json @@ -6,7 +6,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4", - "typescript": "^6", + "typescript": "7.0.1-rc", "wrangler": "^4" } } diff --git a/infra/rpm/bun.lock b/infra/rpm/bun.lock index 0341e56b0..298277f85 100644 --- a/infra/rpm/bun.lock +++ b/infra/rpm/bun.lock @@ -5,7 +5,7 @@ "name": "rpm-moq-dev", "devDependencies": { "@cloudflare/workers-types": "^4", - "typescript": "^6", + "typescript": "7.0.1-rc", "wrangler": "^4", }, }, @@ -149,6 +149,46 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.1-rc", "", { "os": "aix", "cpu": "ppc64" }, "sha512-oqq2ZfEJ7BQuufcC3QBQndZLPNyamYNHLao8lKRBeeSkZKypBqxPSgkzrcFZtbYcIaBvpiyUnQP9MT7DEYHWbw=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.1-rc", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Slc0yTftT2F/uGDmtPst8ijydneL6uZaLEyr2UjahxZpbhTjHFBJ5agXtVz/TL4A+ldxzjzj+E8QtLZlh/5mXw=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.1-rc", "", { "os": "darwin", "cpu": "x64" }, "sha512-h68iFW/LbA1/BsGgSRGFw981/3s1f/rY27YrmeZNuN+ly7dI+fiDduwT9ZT9866x2onoKNRq7PTyxSKyKDzfAQ=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.1-rc", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-DE+ppd8Ix2c6OMuRkKY4PJ4hngMGJ9M95OQUP17p9xL/1IKXof7npIeuusMN/bgL5o5JzMfSGh+N+5scTYRg0Q=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.1-rc", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ST1ozHMw0u+CLOnWkcTyWDMV4Qn9osZ6fd1V1lnKDM1t0hZIp81mdGpdHxyHJjd7jdGrb6Gb/QXcZ1uqZ0t5zw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.1-rc", "", { "os": "linux", "cpu": "arm" }, "sha512-gHmHwT5Naq5CKM8g9bbaGeEpnwQEvWCLn3fwP4K2m61VQdDKkPk0Dhab/OoZ4LV2SrMddmclYXTzpyg23YGt5g=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.1-rc", "", { "os": "linux", "cpu": "arm64" }, "sha512-N46pRihK3t5zD5MUtTQcdmQUqr1WI4U2nxno1gLwOtRSsB4krFkRjPHcQNG7h2DtRkX64rQiReX6WKwg2wprMA=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-G17Sao312rgiPBTh2F4nOpLpa3CcnBSaNhqNghZk2LNhnsp1RaMO5HMq2me21gqu9xLpc6CIgHtOzU6JBgNlfg=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-0FQspOb5UsQ4tQKvWgUO3pS9OIWkP7/8dPRWq+CRazJUeQZ4LBjtYK52jg5iIOrvItrVl2CwvRtrU3/9OihwJg=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.1-rc", "", { "os": "linux", "cpu": "ppc64" }, "sha512-SUmwfVBEv6A2Ld0eWfcvW0FqrgemfQL8jFGOmV1qYxsDqumjE5DekHXqbstgmbE4SHr4rrjHjvmuGCY+kTH/vw=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-rxeqnNnGiYzv/LlPHi/3+4p0ooR1cNJLjRIHXKovtiVmxXGJq6gtw8VSpbHuWPekyFMXgIAoLCZN0SQ51rAALQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.1-rc", "", { "os": "linux", "cpu": "s390x" }, "sha512-RYWCHCiPypxajdRHM2CNK/eM22e4Ex5TTjV2pXf7PTtBowGr0xX8i8kIMknyZS0LX2QfleYHouaoMVsFDSle3g=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.1-rc", "", { "os": "linux", "cpu": "x64" }, "sha512-PfLJSu0JzroDkqw2m4nqflPEcn8yev0m/vHFQlY9EzHorzjR6QG0wL8AJHvnD1e6h1s76AZngJ5u+z1K/D/HKw=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.1-rc", "", { "os": "none", "cpu": "arm64" }, "sha512-FfbPxH3dTfp8yVIaNM7bdWTixXuyxpzoemluqcqMROSIz+ImpCG3Q9HO9Ptzp9/giv+P9YYEnCMSXh61migj2w=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.1-rc", "", { "os": "none", "cpu": "x64" }, "sha512-FzdTfSzhRYb6hlav6K3cI5RVgcvCTvNAu/vc+t7B6AmZkThQ+t/1ntnvT5fnHmY1Az2RIBw7/b+qtCEG61HJTQ=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.1-rc", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-PQGhlxfNig+0YQ9Wwzd0USPBkt6w/ZqkBQWsU7G/0JkTzunJel+jSWwhKw4947pak/m7hGSeYiI04xReDLGZww=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.1-rc", "", { "os": "openbsd", "cpu": "x64" }, "sha512-WJ7NYgO2mHmLUkI/tZ+hl8lFd26QPJqO8ONOHNuYbdsybLvSB6B6sep222JIVrOfPRDGvFinbGGB+l3m1FWRWA=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.1-rc", "", { "os": "sunos", "cpu": "x64" }, "sha512-UYjDeUxd765V9qcwlUPk4pEXyL0i3G76CJm9baK4i99u1pGO1psf3nXDw4MMmElVOPvGbZag99ZR/O59E2OX6w=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.1-rc", "", { "os": "win32", "cpu": "arm64" }, "sha512-KzXzFSXZOm7zvEt2Aw0MsB2LbTL88znAiVqTDNAOHdlEb7brgmUQh/X2wM/8Be+N0fjEqWKl65cBKNwpWEbJiw=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.1-rc", "", { "os": "win32", "cpu": "x64" }, "sha512-98R3+OqDr/r0/PLWEoXu88AE0lGVLNd335Ew8ONgzK1JWkNs4ou/5BGt3Or1ij4iXjH+c7PRL+jFjCbtWze+EA=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -185,7 +225,7 @@ "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=="], + "typescript": ["typescript@7.0.1-rc", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.1-rc", "@typescript/typescript-darwin-arm64": "7.0.1-rc", "@typescript/typescript-darwin-x64": "7.0.1-rc", "@typescript/typescript-freebsd-arm64": "7.0.1-rc", "@typescript/typescript-freebsd-x64": "7.0.1-rc", "@typescript/typescript-linux-arm": "7.0.1-rc", "@typescript/typescript-linux-arm64": "7.0.1-rc", "@typescript/typescript-linux-loong64": "7.0.1-rc", "@typescript/typescript-linux-mips64el": "7.0.1-rc", "@typescript/typescript-linux-ppc64": "7.0.1-rc", "@typescript/typescript-linux-riscv64": "7.0.1-rc", "@typescript/typescript-linux-s390x": "7.0.1-rc", "@typescript/typescript-linux-x64": "7.0.1-rc", "@typescript/typescript-netbsd-arm64": "7.0.1-rc", "@typescript/typescript-netbsd-x64": "7.0.1-rc", "@typescript/typescript-openbsd-arm64": "7.0.1-rc", "@typescript/typescript-openbsd-x64": "7.0.1-rc", "@typescript/typescript-sunos-x64": "7.0.1-rc", "@typescript/typescript-win32-arm64": "7.0.1-rc", "@typescript/typescript-win32-x64": "7.0.1-rc" }, "bin": { "tsc": "bin/tsc" } }, "sha512-drEP77wK7CCDlPfXZH4e008UUQOsw1DFmHmZOZjuNA+yoDLLnSNMZRXi90NbV/1LVo7SbNLq1bs3jjvk49TEqQ=="], "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], diff --git a/infra/rpm/package.json b/infra/rpm/package.json index 6065098d2..971b908fa 100644 --- a/infra/rpm/package.json +++ b/infra/rpm/package.json @@ -6,7 +6,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4", - "typescript": "^6", + "typescript": "7.0.1-rc", "wrangler": "^4" } } diff --git a/js/CLAUDE.md b/js/CLAUDE.md index b47385532..e3d1851f4 100644 --- a/js/CLAUDE.md +++ b/js/CLAUDE.md @@ -7,22 +7,28 @@ Scopes the `/js` TypeScript/JavaScript workspace. Universal rules (writing style Bun workspaces; members listed in the repo-root `package.json` (not in `js/`). Deps hoist to the repo root `node_modules`, not into `js/`. Run recipes via `just js ` (see `js/justfile`). Packages, grouped by role (each mirrors its `rs/` counterpart where one exists), roughly in dependency order: **Foundation** + - `@moq/signals` (`signals/`): reactive core. `Signal`, `Computed`, `Effect`, plus framework adapters at subpaths `./solid`, `./react`, `./dom`. No deps on other workspace packages. Everything below uses it. **Transport / protocol** + - `@moq/net` (`net/`): browser networking. Connect to a relay, then publish/consume broadcasts/tracks/groups/frames over WebTransport (WebSocket fallback). Negotiates `moq-lite` (`lite/`) or IETF `moq-transport` (`ietf/`). Mirror of `rs/moq-net`. Optional `zod` peer dep for `./zod` JSON-frame helpers. **Container / catalog formats** + - `@moq/loc` (`loc/`): Low Overhead Container frame encoding. Thin layer on `@moq/net`. -- `@moq/json` (`json/`): snapshot/delta JSON over a track via RFC 7396 merge-patch. Exposes the base `Producer`/`Consumer` that `@moq/hang`'s catalog extends. +- `@moq/json` (`json/`): snapshot/delta JSON over a track via RFC 7396 merge-patch. Exposes the base `Producer`/`Consumer` that `@moq/hang`'s catalog extends. DEFLATE via `@moq/flate`. +- `@moq/flate` (`flate/`): group-scoped DEFLATE primitive (only deps on `pako`). `Encoder`/`Decoder` turn a stream of payloads into self-delimited sync-flushed frames sharing one window; wire-interoperable with the Rust `moq-flate` crate. Used by `@moq/json`. - `@moq/msf` (`msf/`): MOQT Streaming Format catalog types (zod schemas). **Media** + - `@moq/hang` (`hang/`): WebCodecs media layer. Subpaths `./catalog`, `./container`, `./util`. Mirror of `rs/hang`. Catalog is a JSON track describing other tracks; container frames are timestamp + codec bitstream (CMAF under `container/cmaf`). - `@moq/watch` (`watch/`): subscribe + decode + render, with optional UI. Subpaths `.`, `./element`, `./ui`, `./support`. - `@moq/publish` (`publish/`): capture + encode + publish, with optional UI. Same subpath shape as watch. **Apps / examples** + - `@moq/boy` (`moq-boy/`): MoQ Boy web viewer. The only package using `.tsx`/Solid. - `@moq/clock` (`clock/`): private native example (publish/subscribe a clock). - `@moq/token` (`token/`): JWT generation/validation (`jose`); also ships a `moq-token` bin. Mirror of `rs/moq-token`. diff --git a/js/clock/package.json b/js/clock/package.json index dec6fcc6a..6e8c7b0b0 100644 --- a/js/clock/package.json +++ b/js/clock/package.json @@ -22,7 +22,7 @@ "@fails-components/webtransport-transport-http3-quiche": "^1.6.3" }, "devDependencies": { - "@types/node": "^25.9.2", - "typescript": "^6.0.3" + "@types/node": "^26.0.0", + "typescript": "7.0.1-rc" } } diff --git a/js/flate/README.md b/js/flate/README.md new file mode 100644 index 000000000..c013015d9 --- /dev/null +++ b/js/flate/README.md @@ -0,0 +1,35 @@ +

+ Media over QUIC +

+ +# @moq/flate + +[![npm version](https://img.shields.io/npm/v/@moq/flate)](https://www.npmjs.com/package/@moq/flate) +[![TypeScript](https://img.shields.io/badge/TypeScript-ready-blue.svg)](https://www.typescriptlang.org/) + +Group-scoped DEFLATE: a stream of self-delimited frames sharing one compression window. + +A sequence of frame payloads is compressed into a single raw DEFLATE ([RFC 1951](https://www.rfc-editor.org/rfc/rfc1951.html)) stream, sync-flushed at each frame boundary. Every frame is self-delimited (byte-aligned, the window retained) while later frames reuse the earlier ones as context, so a stream of similar payloads (a snapshot followed by deltas, repeated records, log lines) compresses far better than each payload alone. + +This is plain raw DEFLATE with a `Z_SYNC_FLUSH` after each frame, so it interoperates on the wire with any peer using the same primitive, including the Rust [`moq-flate`](https://crates.io/crates/moq-flate) crate. The fixed 4-byte sync-flush marker is stripped per frame ([RFC 7692](https://www.rfc-editor.org/rfc/rfc7692.html#section-7.2.1)'s permessage-deflate trick). There is no length prefix: the caller frames each slice ([`@moq/net`](../net) already does). + +## Quick Start + +```bash +npm add @moq/flate +``` + +```ts +import { Encoder, Decoder } from "@moq/flate"; + +const encoder = new Encoder(); +const a = encoder.frame(new TextEncoder().encode("the quick brown fox")); +const b = encoder.frame(new TextEncoder().encode("the quick brown dog")); // smaller: reuses the window + +// Feed slices to the decoder in the same order they were produced. +const decoder = new Decoder(); +new TextDecoder().decode(decoder.frame(a)); // "the quick brown fox" +new TextDecoder().decode(decoder.frame(b)); // "the quick brown dog" +``` + +Create a fresh `Encoder`/`Decoder` pair per independent stream (in moq-net terms, per group). `new Encoder({ level })` sets the DEFLATE level (`0..=9`, default `6`); `new Decoder({ maxFrameSize })` caps how far a single frame may inflate (default 64 MiB), rejecting zip bombs. diff --git a/js/flate/package.json b/js/flate/package.json new file mode 100644 index 000000000..ed7b5a235 --- /dev/null +++ b/js/flate/package.json @@ -0,0 +1,28 @@ +{ + "name": "@moq/flate", + "type": "module", + "version": "0.1.0", + "description": "Group-scoped DEFLATE: a stream of self-delimited frames sharing one compression window.", + "license": "(MIT OR Apache-2.0)", + "repository": "github:moq-dev/moq", + "sideEffects": false, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "rimraf dist && tsc -b && bun ../common/package.ts", + "check": "tsc --noEmit", + "test": "bun test --only-failures", + "release": "bun ../common/release.ts" + }, + "dependencies": { + "pako": "^2.2.0" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "@types/pako": "^2.0.4", + "fflate": "^0.8.2", + "rimraf": "^6.1.3", + "typescript": "7.0.1-rc" + } +} diff --git a/js/flate/src/index.test.ts b/js/flate/src/index.test.ts new file mode 100644 index 000000000..3e1c31532 --- /dev/null +++ b/js/flate/src/index.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from "bun:test"; +import { Deflate, Inflate } from "fflate"; +import { Decoder, Encoder } from "./index.ts"; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +function concatBytes(chunks: Uint8Array[]): Uint8Array { + const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +// Round-trip frames through fflate's streaming `Deflate.flush(true)` + `Inflate`, the same +// shared-window scheme our pako codec uses. Returns true only if every frame survives unchanged. +function fflateRoundTrips(frames: Uint8Array[]): boolean { + try { + let captured: Uint8Array[] = []; + const deflate = new Deflate({ level: 6 }); + deflate.ondata = (chunk) => captured.push(chunk); + const slices = frames.map((frame) => { + captured = []; + deflate.push(frame, false); + deflate.flush(true); // sync flush: byte-align and retain the window + return concatBytes(captured); + }); + + let inflated: Uint8Array[] = []; + const inflate = new Inflate(); + inflate.ondata = (chunk) => inflated.push(chunk); + return slices.every((slice, i) => { + inflated = []; + inflate.push(slice, false); + const got = concatBytes(inflated); + return got.length === frames[i].length && got.every((b, j) => b === frames[i][j]); + }); + } catch { + return false; + } +} + +test("codec round-trips a stream of frames in order", () => { + const frames = ["the quick brown fox", "the quick brown dog", "the lazy fox"]; + const encoder = new Encoder(); + const slices = frames.map((f) => encoder.frame(enc.encode(f))); + + const decoder = new Decoder(); + expect(slices.map((s) => dec.decode(decoder.frame(s)))).toEqual(frames); +}); + +test("codec round-trips an empty frame", () => { + const encoder = new Encoder(); + const decoder = new Decoder(); + expect(encoder.frame(new Uint8Array()).length).toBe(0); + expect(decoder.frame(new Uint8Array()).length).toBe(0); +}); + +test("codec rejects garbage", () => { + const decoder = new Decoder(); + expect(() => decoder.frame(new Uint8Array(64).fill(0xff))).toThrow(); +}); + +test("codec rejects frames that inflate past the default cap", () => { + // A tiny slice can inflate enormously, so the decoder bounds the output as it is produced. + const encoder = new Encoder(); + const decoder = new Decoder(); + const slice = encoder.frame(enc.encode("a".repeat(64 * 1024 * 1024 + 1))); + expect(() => decoder.frame(slice)).toThrow(/exceeded/); +}); + +test("codec honors a custom maxFrameSize", () => { + const slice = new Encoder().frame(new Uint8Array(1024)); + const decoder = new Decoder({ maxFrameSize: 512 }); + expect(() => decoder.frame(slice)).toThrow(/exceeded 512/); +}); + +test("a frame larger than pako's chunk size round-trips", () => { + // High-entropy data barely compresses, so the slice spans multiple pako chunks (>16 KB), which + // exercises the encoder's multi-chunk assembly and the decoder's multi-chunk concat. + let state = 0x9e3779b9 >>> 0; + const payload = new Uint8Array(64 * 1024); + for (let i = 0; i < payload.length; i++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + state >>>= 0; + payload[i] = state & 0xff; + } + + const slice = new Encoder().frame(payload); + expect(slice.length).toBeGreaterThan(16 * 1024); // pako's default chunkSize + expect(new Decoder().frame(slice)).toEqual(payload); +}); + +test("cross-frame context shrinks a repeated frame", () => { + // A later frame identical to an earlier one compresses far smaller once the window holds it. + const encoder = new Encoder(); + const payload = enc.encode("Media over QUIC delivers real-time latency at massive scale.".repeat(6)); + const first = encoder.frame(payload); + const second = encoder.frame(payload); + expect(second.length).toBeLessThan(first.length); +}); + +test("a custom level round-trips", () => { + const payload = enc.encode("compress me at maximum effort".repeat(8)); + const slice = new Encoder({ level: 9 }).frame(payload); + expect(new Decoder().frame(slice)).toEqual(payload); +}); + +test("pako round-trips a stream that fflate's flush corrupts", () => { + // A catalog snapshot + 3 deltas that fflate's streaming flush mis-encodes: even fflate's own + // Inflate can't round-trip its output here. This pins why we depend on pako, not the smaller + // fflate. If this ever fails (fflateRoundTrips returns true), fflate may have fixed its sync-flush + // encoder, and dropping the pako dependency could be reconsidered. + const stream = [ + { + video: { + renditions: { + v0: { codec: "avc1.64001f", bitrate: 6000000 }, + v1: { codec: "avc1.640015", bitrate: 3000000 }, + }, + }, + audio: { renditions: { a0: { codec: "opus", bitrate: 128000 } } }, + }, + { video: { renditions: { v0: { bitrate: 6200000 } } } }, + { video: { renditions: { v0: { bitrate: 5800000 } } } }, + { audio: { renditions: { a0: { bitrate: 96000 } } } }, + ]; + const frames = stream.map((value) => enc.encode(JSON.stringify(value))); + + // Our pako codec round-trips every frame of the stream exactly. + const encoder = new Encoder(); + const decoder = new Decoder(); + for (const frame of frames) { + expect(decoder.frame(encoder.frame(frame))).toEqual(frame); + } + + // Positive control: fflate's flush works on simpler frames, so the helper is sound and fflate is + // only selectively broken, not failing for some unrelated reason. + expect(fflateRoundTrips(["the quick brown fox", "the quick brown dog"].map((s) => enc.encode(s)))).toBe(true); + + // fflate's streaming flush does not round-trip the same stream our pako codec handles. + expect(fflateRoundTrips(frames)).toBe(false); +}); diff --git a/js/json/src/compression.ts b/js/flate/src/index.ts similarity index 50% rename from js/json/src/compression.ts rename to js/flate/src/index.ts index db874ff3e..e40d0d24e 100644 --- a/js/json/src/compression.ts +++ b/js/flate/src/index.ts @@ -1,18 +1,23 @@ /** - * Group-scoped DEFLATE compression for the JSON frame stream, using + * Group-scoped DEFLATE: a stream of self-delimited frames sharing one compression window, using * {@link https://github.com/nodeca/pako | pako}'s streaming deflate/inflate. * - * Within a group the frame payloads form a single raw DEFLATE + * A sequence of frame payloads is compressed into a single raw DEFLATE * ([RFC 1951](https://www.rfc-editor.org/rfc/rfc1951.html)) stream, sync-flushed at each frame - * boundary so every frame is self-delimited while later frames reuse the earlier ones as context - * (a snapshot followed by deltas compresses far better than each frame alone). This matches the - * Rust `moq-json` producer, so the two interoperate on the wire. + * boundary, so every frame is self-delimited (byte-aligned, the window retained) while later frames + * reuse the earlier ones as context. A stream of similar payloads (a snapshot followed by deltas, + * repeated records, log lines) compresses far better than each payload alone. Create a fresh + * {@link Encoder}/{@link Decoder} pair per independent stream (in moq-net terms, per group). * - * A sync flush always ends in the fixed 4-byte marker `00 00 ff ff`. {@link Encoder.frame} drops - * it and {@link Decoder.frame} re-appends it, saving 4 bytes per frame, the same trick - * [RFC 7692](https://www.rfc-editor.org/rfc/rfc7692.html#section-7.2.1) (permessage-deflate) uses. - * moq-net frames each slice, so there's no length prefix; {@link Decoder.frame} instead caps the - * inflated output as it is produced. + * This is plain raw DEFLATE with a `Z_SYNC_FLUSH` after each frame, so any peer using the same + * primitive (the Rust `moq-flate` crate, zlib's sync flush) interoperates on the wire. There is no + * length prefix: the caller frames each slice (moq-net already does). + * + * A sync flush always ends in the fixed 4-byte marker `00 00 ff ff`. {@link Encoder.frame} drops it + * and {@link Decoder.frame} re-appends it, saving 4 bytes per frame, the same trick + * [RFC 7692](https://www.rfc-editor.org/rfc/rfc7692.html#section-7.2.1) (permessage-deflate) uses. A + * small slice can still inflate enormously, so {@link Decoder.frame} caps the inflated output as it + * is produced. * * pako is synchronous, so the whole codec is synchronous; it is a normal dependency. * @@ -21,10 +26,11 @@ import * as pako from "pako"; -// Maximum decompressed size of a single frame. A malicious publisher could otherwise send a tiny -// slice that inflates hugely, so {@link Decoder} stops retaining output past this and rejects the -// frame. Mirrors the Rust `MAX_DECOMPRESSED_FRAME`. -const MAX_DECOMPRESSED_FRAME = 64 * 1024 * 1024; +/** The default DEFLATE level ({@link Encoder}): a good size/speed balance for small, repetitive payloads. */ +export const DEFAULT_LEVEL = 6; + +/** The default per-frame decompressed-size cap ({@link Decoder}): 64 MiB. */ +export const DEFAULT_MAX_FRAME_SIZE = 64 * 1024 * 1024; // The trailing bytes of a DEFLATE sync flush, stripped on the wire and re-appended to decode. const SYNC_FLUSH_TAIL = new Uint8Array([0x00, 0x00, 0xff, 0xff]); @@ -42,19 +48,29 @@ function concat(chunks: Uint8Array[], total: number): Uint8Array { return out; } +/** Options for an {@link Encoder}. */ +export interface EncoderOptions { + /** DEFLATE level, `0..=9` (higher is smaller and slower). Defaults to {@link DEFAULT_LEVEL}. */ + level?: number; +} + /** - * Encodes a group's frame payloads into one shared DEFLATE stream, one self-delimited slice per - * frame. Hold one per group; create a new one at each group boundary. + * Encodes a stream's frame payloads into one shared DEFLATE window, one self-delimited slice per + * frame. Hold one per stream; create a new one for each independent stream. * * @public */ export class Encoder { - #deflate = new pako.Deflate({ raw: true }); + #deflate: pako.Deflate; #chunks: Uint8Array[] = []; #total = 0; - /** Start a fresh per-group encoder with a cold window. */ - constructor() { + /** Start a fresh encoder with a cold window. */ + constructor(options: EncoderOptions = {}) { + // `raw`: no zlib header/trailer, matching the Rust side and the browser's `deflate-raw`. + // pako types `level` as a literal union; we accept a plain number and narrow here. + const level = (options.level ?? DEFAULT_LEVEL) as pako.DeflateOptions["level"]; + this.#deflate = new pako.Deflate({ raw: true, level }); this.#deflate.onData = (chunk) => { const bytes = chunk as Uint8Array; this.#chunks.push(bytes); @@ -63,9 +79,8 @@ export class Encoder { } /** - * Compress the next frame's `payload`, returning its slice of the group stream: the DEFLATE bytes - * minus the fixed sync-flush marker. Empty in yields empty out. Slices must be produced in frame - * order. + * Compress the next frame's `payload`, returning its slice of the stream: the DEFLATE bytes minus + * the fixed sync-flush marker. Empty in yields empty out. Slices must be produced in frame order. */ frame(payload: Uint8Array): Uint8Array { if (payload.length === 0) return payload; @@ -74,8 +89,8 @@ export class Encoder { this.#deflate.push(payload, pako.constants.Z_SYNC_FLUSH); // Copy into one tight owned buffer, dropping the trailing sync-flush marker. We can't return - // pako's chunk views: `writeFrame` retains the reference and pako backs each chunk with a - // ~16 KB buffer, so a view would pin far more memory than the frame. + // pako's chunk views: a caller retains the reference and pako backs each chunk with a ~16 KB + // buffer, so a view would pin far more memory than the frame. const out = new Uint8Array(this.#total - SYNC_FLUSH_TAIL.length); let offset = 0; for (const chunk of this.#chunks) { @@ -88,8 +103,17 @@ export class Encoder { } } +/** Options for a {@link Decoder}. */ +export interface DecoderOptions { + /** + * Per-frame decompressed-size cap. A frame that inflates past this is rejected (zip-bomb guard). + * Defaults to {@link DEFAULT_MAX_FRAME_SIZE}. + */ + maxFrameSize?: number; +} + /** - * Decodes a group's frame slices back into the original payloads. Hold one per group; feed slices + * Decodes a stream's frame slices back into the original payloads. Hold one per stream; feed slices * in frame order (each frame builds on the earlier ones). * * @public @@ -99,15 +123,17 @@ export class Decoder { #chunks: Uint8Array[] = []; #total = 0; #tooLarge = false; + #maxFrameSize: number; - /** Start a fresh per-group decoder with a cold window. */ - constructor() { + /** Start a fresh decoder with a cold window. */ + constructor(options: DecoderOptions = {}) { + this.#maxFrameSize = options.maxFrameSize ?? DEFAULT_MAX_FRAME_SIZE; this.#inflate.onData = (chunk) => { const bytes = chunk as Uint8Array; this.#total += bytes.length; // Bound the inflated output as it is produced; a tiny slice can expand enormously. Stop // retaining past the cap, then reject once the push returns. - if (this.#total > MAX_DECOMPRESSED_FRAME) { + if (this.#total > this.#maxFrameSize) { this.#tooLarge = true; return; } @@ -117,7 +143,7 @@ export class Decoder { /** * Decompress the next frame's `slice` back into its payload. Empty in yields empty out. Throws if - * the input is malformed or inflates past the per-frame size limit. + * the input is malformed or inflates past the per-frame size cap. */ frame(slice: Uint8Array): Uint8Array { if (slice.length === 0) return slice; @@ -131,7 +157,7 @@ export class Decoder { this.#inflate.push(slice, false); this.#inflate.push(SYNC_FLUSH_TAIL, pako.constants.Z_SYNC_FLUSH); if (this.#inflate.err) throw new Error(`decompression failed: ${this.#inflate.msg}`); - if (this.#tooLarge) throw new Error(`decompressed frame exceeded ${MAX_DECOMPRESSED_FRAME} bytes`); + if (this.#tooLarge) throw new Error(`decompressed frame exceeded ${this.#maxFrameSize} bytes`); return concat(this.#chunks, this.#total); } diff --git a/js/flate/tsconfig.json b/js/flate/tsconfig.json new file mode 100644 index 000000000..bb55d7c43 --- /dev/null +++ b/js/flate/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src", + "types": ["bun"] + }, + "include": ["src"] +} diff --git a/js/hang/package.json b/js/hang/package.json index 0932dd2ad..36e1cf21c 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -33,6 +33,6 @@ "@typescript/lib-dom": "npm:@types/web@^0.0.350", "fast-glob": "^3.3.3", "rimraf": "^6.1.3", - "typescript": "^6.0.3" + "typescript": "7.0.1-rc" } } diff --git a/js/hang/src/catalog/format.ts b/js/hang/src/catalog/format.ts index 4bf1562ec..25106e295 100644 --- a/js/hang/src/catalog/format.ts +++ b/js/hang/src/catalog/format.ts @@ -5,6 +5,12 @@ // catalog track without explicit configuration; publishers should include the // suffix in the name they publish so consumers can detect it. +/** Track name for the uncompressed hang catalog (the `.json` track). */ +export const TRACK = "catalog.json"; + +/** Track name for the DEFLATE-compressed hang catalog: the `.z` sibling of {@link TRACK}. */ +export const TRACK_COMPRESSED = "catalog.json.z"; + /** Recognized catalog format suffixes used in broadcast names. */ export const FORMATS = ["hang", "msf"] as const; /** A catalog format advertised by a broadcast name suffix. */ diff --git a/js/json/package.json b/js/json/package.json index 5a104a402..431200307 100644 --- a/js/json/package.json +++ b/js/json/package.json @@ -1,7 +1,7 @@ { "name": "@moq/json", "type": "module", - "version": "0.0.3", + "version": "0.1.0", "description": "Snapshot/delta JSON publishing over MoQ tracks using RFC 7396 JSON Merge Patch.", "license": "(MIT OR Apache-2.0)", "repository": "github:moq-dev/moq", @@ -16,18 +16,16 @@ "release": "bun ../common/release.ts" }, "dependencies": { + "@moq/flate": "workspace:^", "@moq/net": "workspace:^", - "@moq/signals": "workspace:^", - "pako": "^2.2.0" + "@moq/signals": "workspace:^" }, "peerDependencies": { "zod": "^4.0.0" }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/pako": "^2.0.4", - "fflate": "^0.8.2", "rimraf": "^6.1.3", - "typescript": "^6.0.3" + "typescript": "7.0.1-rc" } } diff --git a/js/json/src/compression.test.ts b/js/json/src/compression.test.ts index 01970f8c4..1d79b845e 100644 --- a/js/json/src/compression.test.ts +++ b/js/json/src/compression.test.ts @@ -1,7 +1,6 @@ import { expect, test } from "bun:test"; +import { Decoder } from "@moq/flate"; import { TrackProducer, type TrackSubscriber } from "@moq/net"; -import { Deflate, Inflate } from "fflate"; -import { Decoder, Encoder } from "./compression.ts"; import { Consumer } from "./consumer.ts"; import { Producer } from "./producer.ts"; @@ -10,44 +9,6 @@ type Value = Record; const enc = new TextEncoder(); const dec = new TextDecoder(); -function concatBytes(chunks: Uint8Array[]): Uint8Array { - const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); - let offset = 0; - for (const chunk of chunks) { - out.set(chunk, offset); - offset += chunk.length; - } - return out; -} - -// Round-trip frames through fflate's streaming `Deflate.flush(true)` + `Inflate`, the same -// shared-window scheme our pako codec uses. Returns true only if every frame survives unchanged. -function fflateRoundTrips(frames: Uint8Array[]): boolean { - try { - let captured: Uint8Array[] = []; - const deflate = new Deflate({ level: 6 }); - deflate.ondata = (chunk) => captured.push(chunk); - const slices = frames.map((frame) => { - captured = []; - deflate.push(frame, false); - deflate.flush(true); // sync flush: byte-align and retain the window - return concatBytes(captured); - }); - - let inflated: Uint8Array[] = []; - const inflate = new Inflate(); - inflate.ondata = (chunk) => inflated.push(chunk); - return slices.every((slice, i) => { - inflated = []; - inflate.push(slice, false); - const got = concatBytes(inflated); - return got.length === frames[i].length && got.every((b, j) => b === frames[i][j]); - }); - } catch { - return false; - } -} - // Reconstruct every value a compressed consumer yields, in order. async function drainCompressed(track: TrackSubscriber): Promise { const out: Value[] = []; @@ -64,61 +25,16 @@ async function firstFrame(track: TrackSubscriber): Promise { return frame.data; } -test("codec round-trips a group of frames in order", async () => { - const frames = ["the quick brown fox", "the quick brown dog", "the lazy fox"]; - const encoder = new Encoder(); - const slices = frames.map((f) => encoder.frame(enc.encode(f))); - - const decoder = new Decoder(); - expect(slices.map((s) => dec.decode(decoder.frame(s)))).toEqual(frames); -}); - -test("codec round-trips an empty frame", async () => { - const encoder = new Encoder(); - const decoder = new Decoder(); - expect(encoder.frame(new Uint8Array()).length).toBe(0); - expect(decoder.frame(new Uint8Array()).length).toBe(0); -}); - -test("codec rejects garbage", async () => { - const decoder = new Decoder(); - expect(() => decoder.frame(new Uint8Array(64).fill(0xff))).toThrow(); -}); - -test("codec rejects frames that inflate past the cap", async () => { - // A tiny slice can inflate enormously, so the decoder bounds the output as it is produced. - const encoder = new Encoder(); - const decoder = new Decoder(); - const slice = encoder.frame(enc.encode("a".repeat(64 * 1024 * 1024 + 1))); - expect(() => decoder.frame(slice)).toThrow(/exceeded/); -}); - -test("a frame larger than pako's chunk size round-trips", () => { - // High-entropy data barely compresses, so the slice spans multiple pako chunks (>16 KB), which - // exercises the encoder's multi-chunk assembly and the decoder's multi-chunk concat. - let state = 0x9e3779b9 >>> 0; - const payload = new Uint8Array(64 * 1024); - for (let i = 0; i < payload.length; i++) { - state ^= state << 13; - state ^= state >>> 17; - state ^= state << 5; - state >>>= 0; - payload[i] = state & 0xff; +// Count the groups a (finished) track published, draining each so the reads terminate. +async function groupCount(track: TrackSubscriber): Promise { + let groups = 0; + for (;;) { + const group = await track.nextGroup(); + if (!group) return groups; + groups++; + while ((await group.readFrame()) !== undefined) {} } - - const slice = new Encoder().frame(payload); - expect(slice.length).toBeGreaterThan(16 * 1024); // pako's default chunkSize - expect(new Decoder().frame(slice)).toEqual(payload); -}); - -test("cross-frame context shrinks a repeated frame", async () => { - // A later frame identical to an earlier one compresses far smaller once the window holds it. - const encoder = new Encoder(); - const payload = enc.encode("Media over QUIC delivers real-time latency at massive scale.".repeat(6)); - const first = encoder.frame(payload); - const second = encoder.frame(payload); - expect(second.length).toBeLessThan(first.length); -}); +} test("compressed snapshot per group round-trips", async () => { const track = new TrackProducer("test"); @@ -197,42 +113,6 @@ test("compressed deltas reuse the window", async () => { expect(delta.data.length).toBeLessThan(rawDelta.length / 2); }); -test("pako round-trips a group that fflate's flush corrupts", async () => { - // A catalog snapshot + 3 deltas that fflate's streaming flush mis-encodes: even fflate's own - // Inflate can't round-trip its output here. This pins why @moq/json depends on pako, not the - // smaller fflate. If this ever fails (fflateRoundTrips returns true), fflate may have fixed its - // sync-flush encoder, and dropping the pako dependency could be reconsidered. - const group: Value[] = [ - { - video: { - renditions: { - v0: { codec: "avc1.64001f", bitrate: 6000000 }, - v1: { codec: "avc1.640015", bitrate: 3000000 }, - }, - }, - audio: { renditions: { a0: { codec: "opus", bitrate: 128000 } } }, - }, - { video: { renditions: { v0: { bitrate: 6200000 } } } }, - { video: { renditions: { v0: { bitrate: 5800000 } } } }, - { audio: { renditions: { a0: { bitrate: 96000 } } } }, - ]; - const frames = group.map((value) => enc.encode(JSON.stringify(value))); - - // Our pako codec round-trips every frame of the group exactly. - const encoder = new Encoder(); - const decoder = new Decoder(); - for (const frame of frames) { - expect(decoder.frame(encoder.frame(frame))).toEqual(frame); - } - - // Positive control: fflate's flush works on simpler frames, so the helper is sound and fflate is - // only selectively broken, not failing for some unrelated reason. - expect(fflateRoundTrips(["the quick brown fox", "the quick brown dog"].map((s) => enc.encode(s)))).toBe(true); - - // fflate's streaming flush does not round-trip the same group our pako codec handles. - expect(fflateRoundTrips(frames)).toBe(false); -}); - test("compression shrinks a repetitive frame", async () => { const value = { renditions: Array(3).fill("video".repeat(50)) }; @@ -245,3 +125,26 @@ test("compression shrinks a repetitive frame", async () => { const compressedLen = (await firstFrame(compressed.subscribe())).length; expect(compressedLen).toBeLessThan(plainLen); }); + +test("compressed deltas roll on the compressed budget", async () => { + // With compression the budget is measured on compressed frame sizes: #snapshotLen and #deltaBytes + // are the written slice lengths, not the raw JSON. A tight ratio over many distinct updates must + // roll more than one group, and a late joiner must still rebuild the final value across the + // compressed group boundary. Guards against the budget regressing to raw lengths. Two identical + // producers (deterministic output) keep the group-count and reconstruction reads independent. + const fill = (track: TrackProducer) => { + const producer = new Producer(track, { deltaRatio: 2, compression: true }); + for (let n = 0; n <= 40; n++) producer.update({ n }); + producer.finish(); + }; + + const layout = new TrackProducer("layout"); + const layoutSub = layout.subscribe(); + fill(layout); + expect(await groupCount(layoutSub)).toBeGreaterThan(1); + + const reconstruct = new TrackProducer("reconstruct"); + const reconstructSub = reconstruct.subscribe(); + fill(reconstruct); + expect((await drainCompressed(reconstructSub)).at(-1)).toEqual({ n: 40 }); +}); diff --git a/js/json/src/consumer.ts b/js/json/src/consumer.ts index e43276638..f03636a6d 100644 --- a/js/json/src/consumer.ts +++ b/js/json/src/consumer.ts @@ -1,14 +1,15 @@ +import { Decoder } from "@moq/flate"; import type * as Moq from "@moq/net"; import type * as z from "zod/mini"; -import { Decoder } from "./compression.ts"; import { merge } from "./diff.ts"; import type { Config } from "./producer.ts"; /** * Consumes a JSON value from a track, reconstructing it from snapshots and deltas. * - * Reads each group's snapshot (frame 0) and applies the following frames as merge patches, - * yielding the reconstructed value after each one. + * Reads each group's snapshot (frame 0) and applies the following frames as merge patches. A live + * consumer yields each update as it arrives; a consumer that has fallen behind (or just joined) + * collapses the buffered backlog and yields only the latest value. See {@link next}. */ export class Consumer { #track: Moq.TrackSubscriber; @@ -28,7 +29,15 @@ export class Consumer { this.#decompress = config.compression ?? false; } - /** Get the next reconstructed value, or `undefined` once the track ends. */ + /** + * Get the next reconstructed value, or `undefined` once the track ends. + * + * Applies every frame already buffered in the group but yields only the latest reconstructed + * value: the intermediate reconstructions are stale, so a late joiner (or any consumer that has + * fallen behind) catches up to the head in one step instead of replaying every superseded state. + * Frames are still decoded in order (the DEFLATE window and merge patches are sequential); only + * the per-frame yield is skipped. + */ async next(): Promise { for (;;) { if (!this.#group) { @@ -41,6 +50,17 @@ export class Consumer { this.#decoder = undefined; } + // Drain every frame already buffered, keeping only the latest reconstructed value: a late + // joiner (or any consumer that fell behind) catches up to the head in one step. + let value: T | undefined; + let advanced = false; + for (let frame = this.#group.tryReadFrame(); frame !== undefined; frame = this.#group.tryReadFrame()) { + value = this.#apply(frame); + advanced = true; + } + if (advanced) return value; + + // Nothing buffered: block for the next frame (or the group's end). let frame: Moq.Frame | undefined; try { frame = await this.#group.readFrame(); diff --git a/js/json/src/json.test.ts b/js/json/src/json.test.ts index a3a647c3c..46838eec9 100644 --- a/js/json/src/json.test.ts +++ b/js/json/src/json.test.ts @@ -29,7 +29,7 @@ async function structure(track: TrackSubscriber): Promise { test("deltas off: a snapshot group per change", async () => { const track = new TrackProducer("test"); - const producer = new Producer(track); + const producer = new Producer(track, { deltaRatio: 0 }); producer.update({ a: 1 }); producer.update({ a: 2 }); producer.finish(); @@ -38,6 +38,18 @@ test("deltas off: a snapshot group per change", async () => { expect(await drain(track.subscribe())).toEqual([{ a: 1 }, { a: 2 }]); }); +test("deltaRatio 0 disables deltas, like off", async () => { + const track = new TrackProducer("test"); + const producer = new Producer(track, { deltaRatio: 0 }); + producer.update({ a: 1 }); + producer.update({ a: 2 }); + producer.finish(); + + // `0` is treated as off, not a degenerate "enabled" value that keeps the group open: each change + // is its own single-frame snapshot group. + expect(await structure(track.subscribe())).toEqual([1, 1]); +}); + test("live consumer sees each update", async () => { const track = new TrackProducer("test"); const producer = new Producer(track); @@ -139,16 +151,31 @@ test("mutate removes a section", async () => { test("tight ratio rolls snapshots", async () => { const track = new TrackProducer("test"); - // A ratio of 1 admits deltas only up to the snapshot size: with equal 7-byte frames that is a - // single delta per group, so it rolls every other update. + // A ratio of 1 budgets deltas up to one snapshot (equal 7-byte frames => 7 bytes). The gate checks + // the deltas already written, so the delta that tips the group over budget still lands (a one-frame + // overshoot): group 0 takes two deltas (14 bytes) before the fourth update rolls group 1. const producer = new Producer(track, { deltaRatio: 1 }); producer.update({ a: 1 }); // snapshot, group 0 - producer.update({ a: 2 }); // delta, group 0 - producer.update({ a: 3 }); // exceeds budget, rolls group 1 - producer.update({ a: 4 }); // delta, group 1 + producer.update({ a: 2 }); // delta, group 0 (deltas = 7) + producer.update({ a: 3 }); // delta, group 0 (deltas = 14, now over budget) + producer.update({ a: 4 }); // budget already exceeded, rolls group 1 + producer.finish(); + + expect(await structure(track.subscribe())).toEqual([3, 1]); +}); + +test("deltas stay within ratio times snapshot", async () => { + const track = new TrackProducer("test"); + // The budget covers only the deltas, not the snapshot frame, measured against the group's snapshot + // size. Single-digit values keep every frame at a constant 7 bytes (`{"n":N}`), so a ratio of 8 + // budgets 56 bytes of deltas. The gate checks the deltas already written, so the group keeps filling + // until they first exceed 56 (nine deltas = 63 bytes) and the next update rolls (a one-frame + // overshoot past the 56-byte budget). + const producer = new Producer(track, { deltaRatio: 8 }); + for (let n = 0; n <= 10; n++) producer.update({ n }); producer.finish(); - expect(await structure(track.subscribe())).toEqual([2, 2]); + expect(await structure(track.subscribe())).toEqual([10, 1]); }); test("array change is a wholesale delta", async () => { @@ -162,6 +189,20 @@ test("array change is a wholesale delta", async () => { expect(await structure(track.subscribe())).toEqual([2]); }); +test("late joiner collapses a buffered backlog to the latest value", async () => { + const track = new TrackProducer("test"); + const producer = new Producer(track, { deltaRatio: 100 }); + const subscriber = track.subscribe(); + for (let n = 0; n <= 20; n++) { + producer.update({ n }); + } + producer.finish(); + + // A whole group's worth of snapshot + deltas is buffered before the consumer reads, so it applies + // them all but yields only the latest value once, not every superseded state. + expect(await drain(subscriber)).toEqual([{ n: 20 }]); +}); + test("frame cap rolls snapshot", async () => { const track = new TrackProducer("test"); const producer = new Producer(track, { deltaRatio: 1_000_000 }); diff --git a/js/json/src/producer.ts b/js/json/src/producer.ts index 10ca16628..b136ad660 100644 --- a/js/json/src/producer.ts +++ b/js/json/src/producer.ts @@ -1,8 +1,8 @@ +import { Encoder } from "@moq/flate"; import type * as Moq from "@moq/net"; import { Time } from "@moq/net"; import type * as z from "zod/mini"; -import { Encoder } from "./compression.ts"; import { deepEqual, diff } from "./diff.ts"; // Maximum frames (snapshot + deltas) in a single group before a new snapshot is forced. Kept @@ -17,10 +17,13 @@ export interface Config { // // `0` disables deltas: every change is published as a new snapshot group. // - // A positive number enables deltas: a delta is appended to the current group as long as the - // accumulated deltas (excluding the snapshot frame) stay within `deltaRatio` times the size of a - // fresh snapshot; otherwise a new snapshot group is started. So `1` allows deltas totalling up to - // one snapshot before rolling. + // A positive number enables deltas: a new snapshot group is started once the deltas already written + // to the current group (excluding the snapshot frame) exceed `deltaRatio` times the snapshot size. + // The pending delta is excluded from that check, so the one that first crosses the budget still + // lands before the group rolls. So `1` allows roughly one snapshot's worth of deltas before rolling. + // + // When {@link compression} is on, both sides of the comparison are measured on the compressed frame + // sizes (the real wire cost). // // Defaults to `8` when unset. deltaRatio?: number; @@ -46,11 +49,13 @@ export class Producer { #group?: Moq.Group; #last?: unknown; - // Bytes of deltas accumulated in the current group, excluding the snapshot frame. Always raw - // (uncompressed) sizes, even when compressing: the delta-vs-snapshot decision measures raw bytes, - // so a compressed producer rolls groups on raw sizes (still valid on the wire, just a touch sooner - // than the Rust producer, which measures compressed sizes). + // Bytes of deltas already written to the current group, excluding the snapshot frame. Compressed + // frame sizes when compressing, raw otherwise, matching {@link #snapshotLen} so the budget check is + // like-for-like (and identical to the Rust producer). #deltaBytes = 0; + // Size of the current group's snapshot frame, the reference the delta budget is measured against. + // Compressed when compressing, raw otherwise. + #snapshotLen = 0; #groupFrames = 0; // Group-scoped `deflate-raw` compression. `#encoder` is the current group's stream, swapped for a @@ -75,10 +80,9 @@ export class Producer { if (this.#last !== undefined && deepEqual(this.#last, json)) return; const snapshot = new TextEncoder().encode(text); - const delta = this.#delta(json, snapshot.length); + const delta = this.#delta(json); if (delta && this.#group) { - this.#writeDelta(this.#group, delta); - this.#deltaBytes += delta.length; + this.#deltaBytes += this.#writeDelta(this.#group, delta); this.#groupFrames += 1; } else { this.#snapshot(snapshot); @@ -129,21 +133,24 @@ export class Producer { return this.#config.deltaRatio ?? DEFAULT_DELTA_RATIO; } - #delta(json: unknown, snapshotLen: number): Uint8Array | undefined { + // Build a delta frame, or `undefined` to signal that a fresh snapshot should be published. + // + // The budget gate runs first, against the deltas already written, so rolling a new group costs no + // merge-patch work. Since the gate excludes the frame about to be written, the delta that tips the + // group past `ratio * snapshot` still lands: a group overshoots the budget by at most one delta. + #delta(json: unknown): Uint8Array | undefined { const ratio = this.#deltaRatio; if (ratio === 0) return undefined; if (this.#last === undefined) return undefined; if (!this.#group || this.#groupFrames >= MAX_DELTA_FRAMES) return undefined; + // Gate on the deltas accumulated so far (snapshot frame excluded), before computing the patch. + if (this.#deltaBytes > ratio * this.#snapshotLen) return undefined; + const result = diff(this.#last, json); if (result.forcedSnapshot) return undefined; - const delta = new TextEncoder().encode(JSON.stringify(result.patch)); - - // Roll a snapshot once the deltas would outgrow the budget (snapshot frame excluded). - if (this.#deltaBytes + delta.length > ratio * snapshotLen) return undefined; - - return delta; + return new TextEncoder().encode(JSON.stringify(result.patch)); } #snapshot(snapshot: Uint8Array): void { @@ -151,7 +158,7 @@ export class Producer { this.#group?.close(); const group = this.#track.appendGroup(); - this.#writeSnapshot(group, snapshot); + this.#snapshotLen = this.#writeSnapshot(group, snapshot); this.#deltaBytes = 0; this.#groupFrames = 1; @@ -165,24 +172,27 @@ export class Producer { } } - // Write a group's snapshot (frame 0). On the compressed path this opens a fresh per-group encoder - // (cold window), so the snapshot and its deltas share one DEFLATE stream. - #writeSnapshot(group: Moq.Group, frame: Uint8Array): void { + // Write a group's snapshot (frame 0), returning the bytes written. On the compressed path this opens + // a fresh per-group encoder (cold window), so the snapshot and its deltas share one DEFLATE stream. + #writeSnapshot(group: Moq.Group, frame: Uint8Array): number { let data = frame; if (this.#compress) { this.#encoder = new Encoder(); data = this.#encoder.frame(frame); } group.writeFrame({ data, timestamp: Time.Timestamp.now() }); + return data.length; } - // Write a delta frame, compressed against the current group's encoder when compressing. - #writeDelta(group: Moq.Group, frame: Uint8Array): void { + // Write a delta frame, compressed against the current group's encoder when compressing. Returns the + // bytes written. + #writeDelta(group: Moq.Group, frame: Uint8Array): number { let data = frame; if (this.#compress) { if (!this.#encoder) throw new Error("compressed delta requires an open group"); data = this.#encoder.frame(frame); } group.writeFrame({ data, timestamp: Time.Timestamp.now() }); + return data.length; } } diff --git a/js/loc/package.json b/js/loc/package.json index 8ce7fe140..10ab5ec45 100644 --- a/js/loc/package.json +++ b/js/loc/package.json @@ -20,6 +20,6 @@ "devDependencies": { "@types/bun": "^1.3.14", "rimraf": "^6.1.3", - "typescript": "^6.0.3" + "typescript": "7.0.1-rc" } } diff --git a/js/moq-boy/package.json b/js/moq-boy/package.json index f04a8094b..f200211da 100644 --- a/js/moq-boy/package.json +++ b/js/moq-boy/package.json @@ -32,10 +32,10 @@ "//devDependencies": "These are only needed for local development with workspace packages. They are NOT needed when using the published npm packages.", "devDependencies": { "@types/bun": "^1.3.14", - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "rimraf": "^6.1.3", "solid-js": "^1.9.13", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12" } diff --git a/js/msf/package.json b/js/msf/package.json index b9af21164..c33f6729b 100644 --- a/js/msf/package.json +++ b/js/msf/package.json @@ -20,6 +20,6 @@ }, "devDependencies": { "rimraf": "^6.1.3", - "typescript": "^6.0.3" + "typescript": "7.0.1-rc" } } diff --git a/js/msf/src/catalog.ts b/js/msf/src/catalog.ts index 2acec492b..38bebb676 100644 --- a/js/msf/src/catalog.ts +++ b/js/msf/src/catalog.ts @@ -127,6 +127,8 @@ export function decode(raw: Uint8Array): Catalog { // Resolve draft-01 initRef into inline initData so callers never see // the indirection. Inline initData (draft-00) is left untouched. if (track.initData === undefined && initRef !== undefined) { + // Only inline entries are resolved here. A non-inline (e.g. `url`) initRef + // legitimately stays undefined; the consumer fetches that init out-of-band. track.initData = inline.get(initRef); } return track; diff --git a/js/net/package.json b/js/net/package.json index c474c6d4d..768f79ee3 100644 --- a/js/net/package.json +++ b/js/net/package.json @@ -26,10 +26,10 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/node": "^25.9.2", + "@types/node": "^26.0.0", "@typescript/lib-dom": "npm:@types/web@^0.0.350", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite-plugin-html": "^3.2.2" } } diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 4f2ea2164..f57593205 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -2,6 +2,8 @@ import { expect, test } from "bun:test"; import { CacheFull, Group, MAX_GROUP_CACHE_BYTES, MAX_GROUP_FRAMES } from "./group.ts"; import { Timestamp } from "./time.ts"; +const dec = new TextDecoder(); + test("a group caps its frame count, dropping from the front", () => { const group = new Group(0); @@ -55,3 +57,76 @@ test("a group with no eviction reads every frame without error", async () => { expect((await group.readFrame())?.data[0]).toBe(2); expect(await group.readFrame()).toBeUndefined(); }); + +test("tryReadFrame drains buffered frames then returns undefined", () => { + const group = new Group(0); + group.writeString("a"); + group.writeString("b"); + + expect(dec.decode(group.tryReadFrame())).toBe("a"); + expect(dec.decode(group.tryReadFrame())).toBe("b"); + // Nothing buffered: undefined, and the group is not closed so this is not end-of-group. + expect(group.tryReadFrame()).toBeUndefined(); +}); + +test("tryReadFrameSequence reports per-frame sequence numbers", () => { + const group = new Group(7); + group.writeString("a"); + group.writeString("b"); + + expect(group.tryReadFrameSequence()).toEqual({ sequence: 0, data: new TextEncoder().encode("a") }); + expect(group.tryReadFrameSequence()).toEqual({ sequence: 1, data: new TextEncoder().encode("b") }); + expect(group.tryReadFrameSequence()).toBeUndefined(); +}); + +test("done distinguishes a finished group from one that is merely empty", () => { + const group = new Group(0); + // Open and empty: not done (more frames may arrive), and tryReadFrame is undefined. + expect(group.tryReadFrame()).toBeUndefined(); + expect(group.done).toBe(false); + + group.writeString("a"); + // Buffered but closed: still not done until the frame is drained. + group.close(); + expect(group.done).toBe(false); + + group.tryReadFrame(); + // Drained and closed: now done. + expect(group.tryReadFrame()).toBeUndefined(); + expect(group.done).toBe(true); +}); + +test("readable resolves once a frame is buffered", async () => { + const group = new Group(0); + // No frame yet: readable() must stay pending for an empty, open group. + const readable = group.readable(); + let settled = false; + void readable.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + // Writing makes it resolve. + group.writeString("hi"); + await readable; // must not hang + expect(dec.decode(group.tryReadFrame())).toBe("hi"); +}); + +test("readable resolves once the group closes, even with nothing buffered", async () => { + const group = new Group(0); + const readable = group.readable(); + group.close(); + await readable; // resolves on close so callers don't wait forever + expect(group.tryReadFrame()).toBeUndefined(); +}); + +test("buffered frames are still readable after the group closes", async () => { + const group = new Group(0); + group.writeString("a"); + group.close(); + + // Closing doesn't discard buffered frames; the blocking reader drains them before ending. + expect(await group.readString()).toBe("a"); + expect(await group.readFrame()).toBeUndefined(); +}); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index f0c175ef6..0c093d684 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -149,6 +149,54 @@ export class Group { this.writeFrame({ data: new Uint8Array([bool ? 1 : 0]), timestamp: Timestamp.now() }); } + /** True once no further frames can be read: the group has closed and every buffered frame is read. */ + get done(): boolean { + return this.state.frames.peek().length === 0 && this.state.closed.peek() !== false; + } + + /** + * Reads the next already-buffered frame's payload without blocking. + * + * Returns `undefined` when nothing is buffered right now. That is *not* by itself end-of-group: + * check {@link done} to tell "no frame buffered yet" (more may arrive) from "the group finished". + * Drain a backlog by looping until this returns `undefined`, then branch on {@link done}: if not + * done, {@link readable} resolves when the next frame arrives. + * + * Non-throwing: unlike {@link readFrame} it does not raise {@link CacheFull} on an evicted prefix. + */ + tryReadFrame(): Uint8Array | undefined { + return this.tryReadFrameSequence()?.data; + } + + /** + * Like {@link tryReadFrame} but also reports the frame's sequence number within the group. + * + * Non-throwing: it does not raise {@link CacheFull} on an evicted prefix. The eviction check + * lives only in the blocking {@link readFrame}/{@link readFrameSequence} paths. + */ + tryReadFrameSequence(): { sequence: number; data: Uint8Array } | undefined { + const frames = this.state.frames.peek(); + const frame = frames.shift(); + if (frame === undefined) return undefined; + return { sequence: this.state.total.peek() - frames.length - 1, data: frame.data }; + } + + /** + * Resolves once {@link readFrame} would not block: a frame is buffered, or the group has closed. + * Always settles (never hangs), so on a finished group it resolves immediately; pair it with + * {@link done} to avoid re-waiting on a group that has nothing left. + * + * Lets a caller fold "this group has a frame" into a larger wait (e.g. racing it against a new + * group arriving) without touching the group's internal signals. + */ + async readable(): Promise { + for (;;) { + if (this.state.frames.peek().length > 0) return; + if (this.state.closed.peek()) return; + await Signal.race(this.state.frames, this.state.closed); + } + } + /** * Reads the next frame (timestamp + payload) from the group. * @returns A promise that resolves to the next frame or undefined diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 59be4816b..c3b2d4352 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -53,3 +53,24 @@ test("nextGroup returns undefined when track closes", async () => { producer.close(); expect(await track.nextGroup()).toBeUndefined(); }); + +test("readFrame does not livelock when a sole group finishes before the next arrives", async () => { + const producer = new TrackProducer("test"); + const track = producer.subscribe(); + + // A group is appended then finished empty while the track stays open. A finished group's + // readable() resolves immediately, so the reader must not busy-wait on it (which would starve the + // macrotask queue and never observe the next group). + const g0 = producer.appendGroup(); + g0.close(); + + // The next group arrives via a macrotask; if the reader livelocks on microtasks it never runs. + setTimeout(() => { + const g1 = producer.appendGroup(); + g1.writeString("hello"); + g1.close(); + producer.close(); + }, 10); + + expect(await track.readString()).toBe("hello"); +}, 2000); diff --git a/js/publish/package.json b/js/publish/package.json index 917d31367..a75213451 100644 --- a/js/publish/package.json +++ b/js/publish/package.json @@ -34,9 +34,9 @@ "@types/audioworklet": "^0.0.100", "@types/bun": "^1.3.14", "@typescript/lib-dom": "npm:@types/web@^0.0.350", - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16" } } diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index bed359255..cb2745881 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -14,7 +14,9 @@ export type BroadcastProps = { }; export class Broadcast { - static readonly CATALOG_TRACK = "catalog.json"; + static readonly CATALOG_TRACK = Catalog.TRACK; + /** The DEFLATE-compressed catalog track, served alongside {@link CATALOG_TRACK} with identical content. */ + static readonly CATALOG_TRACK_COMPRESSED = Catalog.TRACK_COMPRESSED; connection: Signal; enabled: Signal; @@ -101,6 +103,10 @@ export class Broadcast { case Broadcast.CATALOG_TRACK: serve = (track, effect) => this.catalog.serve(track, effect); break; + case Broadcast.CATALOG_TRACK_COMPRESSED: + // Same catalog, DEFLATE-compressed; consumers opt in by subscribing to this track. + serve = (track, effect) => this.catalog.serve(track, effect, { compression: true }); + break; case Audio.Encoder.TRACK: serve = (track, effect) => this.audio.serve(track, effect); break; diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index ed371cba2..bdac86c03 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -24,9 +24,14 @@ export class CatalogProducer { for (const output of this.#outputs) output.update(value); } - /** Serve a subscription request: seed it with the current catalog, then forward updates. */ - serve(track: Moq.TrackProducer, effect: Effect): void { - const output = new Json.Producer(track); + /** + * Serve a subscription request: seed it with the current catalog, then forward updates. + * + * Pass `opts.compression` to DEFLATE-compress this subscriber's frames, so the same catalog can be + * served both plaintext and compressed (e.g. `catalog.json` and `catalog.json.z`). + */ + serve(track: Moq.TrackProducer, effect: Effect, opts?: { compression?: boolean }): void { + const output = new Json.Producer(track, { compression: opts?.compression }); output.update(this.#value); this.#outputs.add(output); diff --git a/js/signals/package.json b/js/signals/package.json index f69dea5d0..7cf578589 100644 --- a/js/signals/package.json +++ b/js/signals/package.json @@ -19,7 +19,7 @@ "release": "bun ../common/release.ts" }, "devDependencies": { - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "rimraf": "^6.1.3", "@types/bun": "^1.3.11", "@types/react": "^19.2.17", diff --git a/js/signals/src/dom.ts b/js/signals/src/dom.ts index 05121c719..0711c1541 100644 --- a/js/signals/src/dom.ts +++ b/js/signals/src/dom.ts @@ -15,7 +15,7 @@ export type CreateOptions = { id?: string; dataset?: Record; attributes?: Record; -} & Partial>; +} & Partial>; /** Creates an HTML element, applying the given options and appending the given children. */ export function create( diff --git a/js/token/package.json b/js/token/package.json index f1436e86a..1e5e8f0b9 100644 --- a/js/token/package.json +++ b/js/token/package.json @@ -26,8 +26,8 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/node": "^25.9.2", + "@types/node": "^26.0.0", "rimraf": "^6.1.3", - "typescript": "^6.0.3" + "typescript": "7.0.1-rc" } } diff --git a/js/watch/README.md b/js/watch/README.md index d9f24fd8d..4c0db7123 100644 --- a/js/watch/README.md +++ b/js/watch/README.md @@ -70,7 +70,7 @@ The simplest way to watch a stream: | `name` | string | required | Broadcast name/path | | `paused` | boolean | false | Pause playback | | `muted` | boolean | false | Mute audio | -| `visible` | `never` \| distance \| `always` | `0px` | When to download video (see below) | +| `visible` | never, distance, or always | `0px` | When to download video (see below) | | `volume` | number | 0.5 | Audio volume (0-1) | The `visible` attribute controls when the video track is downloaded, based on the canvas diff --git a/js/watch/package.json b/js/watch/package.json index af848230b..c38d7d794 100644 --- a/js/watch/package.json +++ b/js/watch/package.json @@ -35,9 +35,9 @@ "@types/audioworklet": "^0.0.100", "@types/bun": "^1.3.14", "@typescript/lib-dom": "npm:@types/web@^0.0.350", - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16" } } diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index dd5d92e6b..4d5964ea1 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -7,9 +7,11 @@ import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Si import { toHang } from "./msf"; -// Watch supports the two on-the-wire catalog formats from @moq/hang plus a -// "manual" mode where the user supplies the catalog directly without fetching. -export const CATALOG_FORMATS = [...Catalog.FORMATS, "manual"] as const; +// Watch supports the on-the-wire catalog formats from @moq/hang, plus "hangz" (the +// DEFLATE-compressed `catalog.json.z` track) and a "manual" mode where the user supplies the +// catalog directly without fetching. "hangz" is opt-in only: it shares the `.hang` broadcast suffix +// and is never auto-detected, so set it explicitly via `catalogFormat`. +export const CATALOG_FORMATS = [...Catalog.FORMATS, "hangz", "manual"] as const; export type CatalogFormat = (typeof CATALOG_FORMATS)[number]; export function parseCatalogFormat(value: string | null): CatalogFormat | undefined { @@ -36,9 +38,8 @@ type BroadcastInput = { reload: Getter; // Which catalog format to use. When `undefined` (the default), the format is - // auto-detected from the broadcast name extension (`.hang`, `.msf`), falling - // back to `"hang"` if the name has no recognized extension. Set to a - // specific value to override auto-detection. + // specific value to override auto-detection. `"hangz"` (the compressed + // `catalog.json.z` track) is opt-in only and never auto-detected. catalogFormat: Getter; // The manual-mode catalog source. Used directly when catalogFormat is "manual"; @@ -154,15 +155,18 @@ export class Broadcast { this.#output.status.set("loading"); - const trackName = format === "hang" ? "catalog.json" : "catalog"; + const trackName = format === "hang" ? Catalog.TRACK : format === "hangz" ? Catalog.TRACK_COMPRESSED : "catalog"; const track = broadcast.track(trackName).subscribe({ priority: Catalog.PRIORITY.catalog }); effect.cleanup(() => track.close()); - // The hang catalog is reconstructed from snapshots (and future deltas) via @moq/json; - // MSF stays on its own one-blob-per-group fetch. + // The hang catalog is reconstructed from snapshots (and future deltas) via @moq/json, with + // "hangz" decompressing the `.z` track; MSF stays on its own one-blob-per-group fetch. let fetchNext: () => Promise; - if (format === "hang") { - const consumer = new Json.Consumer(track, { schema: Catalog.RootSchema }); + if (format === "hang" || format === "hangz") { + const consumer = new Json.Consumer(track, { + schema: Catalog.RootSchema, + compression: format === "hangz", + }); fetchNext = () => consumer.next(); } else { fetchNext = async () => { diff --git a/nix/overlay.nix b/nix/overlay.nix index 30b79b123..8f8e34371 100644 --- a/nix/overlay.nix +++ b/nix/overlay.nix @@ -46,6 +46,12 @@ let # jemalloc's configure uses -O0 test builds, which conflict with # Nix's _FORTIFY_SOURCE hardening (requires -O). hardeningDisable = [ "fortify" ]; + # Auth::new builds a rustls client config up front, which loads native + # roots and now errors when none are found. The build sandbox has no + # system trust store, so point rustls-native-certs at cacert's bundle + # for the check phase (even the http-only auth tests hit this path). + nativeBuildInputs = [ final.cacert ]; + SSL_CERT_FILE = "${final.cacert}/etc/ssl/certs/ca-bundle.crt"; }; moqCliArgs = crateInfo ../rs/moq-cli/Cargo.toml // { diff --git a/package.json b/package.json index 99d0124fa..aae1f17d4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "moq", "version": "0.0.0", "devDependencies": { - "@biomejs/biome": "^2.4.16", + "@biomejs/biome": "^2.5.0", "concurrently": "^10.0.3", "publint": "^0.3.21", "remark-cli": "^12.0.1", @@ -29,6 +29,7 @@ "js/net", "js/clock", "js/token", + "js/flate", "js/json", "js/hang", "js/loc", diff --git a/rs/CLAUDE.md b/rs/CLAUDE.md index 579307f3a..a4737d0dc 100644 --- a/rs/CLAUDE.md +++ b/rs/CLAUDE.md @@ -9,30 +9,37 @@ Workspace members live in the root `Cargo.toml` (`[workspace]`). `rust-version = Layered roughly transport -> container/format -> media -> apps/bindings. **Transport / protocol** + - `moq-net` (lib): the core wire layer. Negotiates `moq-lite` or IETF `moq-transport`. Owns the Broadcast/Track/Group/Frame model and the Producer/Consumer split (see below). Generic over `web_transport_trait::Session` (no concrete QUIC dep). Submodules are private; the public surface is re-exported flat from the crate root. - `moq-native` (lib): native connection helpers. `ClientConfig`/`ServerConfig` wrap QUIC backends (Quinn/Quiche/Noq/Iroh), WebTransport, WebSocket, TCP (qmux), Unix sockets, TLS, cert hot-reload, logging, jemalloc. Re-exports `moq_net`. Example: `examples/clock.rs`. - `kio` (lib): "easy async". `Producer`/`Consumer` shared-state channels with `Waiter`-based notification, built on `std::task::Waker`, no runtime dependency. Underpins all the `poll_*` plumbing in moq-net and moq-mux. `src/producer.rs`, `src/consumer.rs`, `src/waiter.rs`. -**Container / catalog formats** (standalone specs, mostly no moq-* deps, reused by moq-mux) +**Container / catalog formats** (standalone specs, mostly no moq-\* deps, reused by moq-mux) + - `hang` (lib): media layer on `moq-net`. `catalog/` is the JSON manifest (`Catalog`, root.rs); `container/` is the frame format (timestamp + codec payload, `container::Frame`). - `moq-loc` (lib): LOC (Low Overhead Container) wire frame codec. Top-level `encode`/`decode` + `Frame`. QUIC varints, property KVPs. - `moq-msf` (lib): IETF MSF/CMSF catalog types (`Catalog`, `Track`, `Packaging`, `Role`). serde JSON. Alternative to hang's catalog. -- `moq-json` (lib): generic snapshot/delta value publishing over a track using RFC 7396 JSON Merge Patch. `Producer`/`Consumer`, `Guard` (RAII edit). Late joiners reconstruct from snapshot + deltas. +- `moq-json` (lib): generic snapshot/delta value publishing over a track using RFC 7396 JSON Merge Patch. `Producer`/`Consumer`, `Guard` (RAII edit). Late joiners reconstruct from snapshot + deltas. DEFLATE via `moq-flate`. +- `moq-flate` (lib): group-scoped DEFLATE primitive (no moq deps). `Encoder`/`Decoder` turn a stream of payloads into self-delimited sync-flushed frames sharing one window (RFC 7692 marker trick), so similar frames compress against the earlier ones. Used by `moq-json`; reusable by any framed stream. **Media bridge / codecs** -- `moq-mux` (lib): the conversion layer. File/stream formats (`container/`: fmp4, flv, hls, mkv, ts, loc) and codec parsers (`codec/`: h264, h265, av1, vp8/9, opus, aac, ...) <-> hang broadcasts. `Container` trait + generic `Producer`/`Consumer`. Dual catalog (`catalog::hang`, `catalog::msf`). + +- `moq-mux` (lib): the conversion layer. File/stream formats (`container/`: fmp4, flv, mkv, ts, loc) and codec parsers (`codec/`: h264, h265, av1, vp8/9, opus, aac, ...) <-> hang broadcasts. `Container` trait + generic `Producer`/`Consumer`. Dual catalog (`catalog::hang`, `catalog::msf`). - `moq-audio` (lib): native PCM <-> Opus (`unsafe-libopus`). `AudioProducer`/`AudioConsumer`, `Encoder`/`Decoder`, `AudioFormat`. Optional `capture` feature (cpal microphone), `resample`. - `moq-video` (lib): native webcam capture + H.264 via `ffmpeg-next`. `capture::Config`, `encode::{Encoder, Producer, publish_capture}`. ffmpeg types kept out of the public signature (see `error/`). **Apps / binaries** + - `moq-relay` (lib+bin): clusterable, media-agnostic relay. axum HTTP API, JWT auth, WebSocket fallback, clustering. Config/TOML merge pattern lives here (see below). - `moq-rtc` (lib+bin): WebRTC (WHIP/WHEP) gateway. Bridges browser WebRTC ingest/playback to MoQ broadcasts (ICE/DTLS, A/V sync, NACK). - `moq-cli` (lib+bin, `moq`): serve/accept/publish/subscribe; stdin/stdout media piping. +- `moq-rtc` (lib+bin): WebRTC (WHIP/WHEP) gateway. Bridges browser WebRTC ingest/playback to MoQ broadcasts (str0m ICE/DTLS, A/V sync, NACK). Embeddable lib (`default-features = false`) + standalone binary (`server` feature). - `moq-bench` (bin): relay load generator. `JoinSet`-spawned staggered connections, rand sampling. - `moq-boy` (bin): crowd-controlled Game Boy emulator publisher (blocking emulator thread + async monitor tasks). - `moq-token` (lib) / `moq-token-cli` (bin): JWT auth. `Claims`, `Algorithm`, `KeyType` (EC/RSA/OCT/OKP), JWKS. CLI does generate/sign/verify. **Bindings** + - `moq-ffi` (cdylib+staticlib): UniFFI bindings (Python/Swift/Kotlin/Go). Proc-macro based (`uniffi::setup_scaffolding!("moq")`, `#[uniffi::Object]`/`#[uniffi::export]`), no `.udl`. Exposes `Moq*Producer`/`Moq*Consumer`, `MoqError` (`#[uniffi(flat_error)]`). - `libmoq` (staticlib): C bindings. `cbindgen` `build.rs` emits `moq.h` + pkg-config. `extern "C"` over opaque handles; dedicated tokio runtime thread (`LazyLock`). - `moq-gst` (cdylib): GStreamer plugin. `gst::plugin_define!`, `moqsrc`/`moqsink` elements bridging to a background tokio task. @@ -52,6 +59,7 @@ The whole stack is built on a split-handle pattern: a `Producer` writes, one or ## Async / poll plumbing Two ways to drive things, both backed by `kio`: + - `async fn` (requires an active tokio runtime; awaiting outside one may panic, see `moq-net/src/lib.rs:42`). - `poll_*` counterparts that take a `&kio::Waiter` and return `Poll<...>`, drivable from any executor or synchronously (`kio` is built on `std::task::Waker`). The `async` method usually just wraps the `poll_*` one via `kio::wait`. Example pair: `TrackConsumer::poll_recv_group` / `recv_group` (`moq-net/src/model/track.rs:502,518`). @@ -75,7 +83,7 @@ Negotiation: `version::NEGOTIATED` lists SETUP-negotiated versions in preference - **Prefer `kio` over tokio sync primitives**: reach for `kio::Producer`/`Consumer` (and the `poll_*` plumbing) instead of `tokio::sync` channels or `watch`. A `tokio::sync::watch` (or a channel) carrying a single value is a code smell. `kio` ties into the runtime-free `poll_*` model and avoids a hard runtime dependency. - **Errors**: `thiserror` with `#[from]` for libraries, `anyhow` (with `.context("...")`, not `.map_err(|_| anyhow!())`) for binaries. Always `#[non_exhaustive]` on public error enums (e.g. `moq-net/src/error.rs:6`, `moq-ffi/src/error.rs:4`, `moq-loc/src/lib.rs:55`). Use `#[error(transparent)]` + `#[from]` for wrapped foreign errors (see `moq-token/src/error.rs`). - **Config + TOML merge**: any `#[arg]` field on a TOML-loadable config must be `Option`, never a bare `bool`/`String`/etc. The TOML->CLI merge re-applies clap defaults and silently clobbers TOML values for bare fields. See `moq-relay/src/config.rs` and its regression tests (`cli_does_not_clobber_toml_*`, around line 126); add such a test for any new flag. -- **Config structs**: `#[derive(Parser, Serialize, Deserialize)]` with `#[serde(deny_unknown_fields, default)]`, clap `#[arg(long, env = "MOQ_...")]`, nested configs via `#[command(flatten)]`, and an `.init()`/`.load()` method that produces the live object. Add `#[non_exhaustive]` + `Default`/constructor to configs consumers build (per root Public API Scrutiny). +- **Config structs**: `#[derive(Parser, Serialize, Deserialize)]` with `#[serde(deny_unknown_fields, default)]`, clap `#[arg(long, env = "MOQ_...")]`, nested configs via `#[command(flatten)]`, and an `.init()`/`.load()` method that produces the live object. Add `#[non_exhaustive]` + `Default`/constructor to **public-field** configs consumers build (per root Public API Scrutiny). A struct whose fields are all private and built through a builder (e.g. `select::Broadcast`) already blocks struct literals, so it doesn't need `#[non_exhaustive]`. - **Unwrapping**: prefer `if let Some(v) = x { ... }` / `let Some(v) = x else { ... };` over a `match` whose only job is to bind the inner value. Keep `match` when both arms do real work. - **Naming**: role-based module + short unprefixed type (`encode::Encoder`, `capture::Config`), not `EncoderConfig`/`CameraConfig`. Re-export flat to avoid stutter (`mod encoder` private, `pub use encoder::Encoder`). diff --git a/rs/hang/README.md b/rs/hang/README.md index dfcc39974..9106d5133 100644 --- a/rs/hang/README.md +++ b/rs/hang/README.md @@ -22,7 +22,7 @@ We most of the implement the [WebCodecs specification](https://www.w3.org/TR/web ## CMAF Import -For importing fMP4/CMAF/HLS files, see the `moq-mux` crate. +For importing fMP4/CMAF files, see the `moq-mux` crate. For HLS, see the `moq-hls` crate. ## Examples diff --git a/rs/moq-audio/src/codec.rs b/rs/moq-audio/src/codec.rs index 6007173b2..ecacb41f6 100644 --- a/rs/moq-audio/src/codec.rs +++ b/rs/moq-audio/src/codec.rs @@ -290,11 +290,14 @@ impl Encoder { /// hang catalog entry describing this encoder's output stream. pub fn catalog(&self) -> hang::catalog::AudioConfig { + // `codec_channels` is validated to mono/stereo at encoder construction, so the + // OpusHead (channel mapping family 0) always encodes. let head = moq_mux::codec::opus::Config { sample_rate: self.codec_rate, channel_count: self.codec_channels, } - .encode(); + .encode() + .expect("opus encoder channels validated to mono/stereo"); let mut config = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, self.codec_rate, self.codec_channels); diff --git a/rs/moq-bench/README.md b/rs/moq-bench/README.md index 109853044..1de608f7b 100644 --- a/rs/moq-bench/README.md +++ b/rs/moq-bench/README.md @@ -74,7 +74,7 @@ table (`fps = { min = 24, max = 60 }`). | `--duration` | | Stop after this long (runs until interrupted otherwise) | | `--report` | | How often to log throughput stats | -Client TLS/QUIC flags (`--tls-disable-verify`, `--client-bind`, ...) come from +Client TLS/QUIC flags (`--client-tls-disable-verify`, `--client-bind`, ...) come from `moq-native` and behave the same as in `moq-cli` and `moq-relay`. ## Presets diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index 016a80718..9ace9f7c1 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -42,14 +42,14 @@ clap = { version = "4", features = ["derive"] } hang = { workspace = true } humantime = "2.3" moq-audio = { workspace = true, optional = true } -# Ingest only (no HTTP egress server); the lib provides the relocated HLS importer. -moq-hls = { workspace = true } +# `server` enables the HTTP egress server for `moq hls export`; the importer is always available. +moq-hls = { workspace = true, features = ["server"] } moq-mux = { workspace = true } moq-native = { workspace = true, default-features = false, features = ["aws-lc-rs"] } moq-video = { workspace = true, optional = true } rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false } tokio = { workspace = true, features = ["full"] } -tower-http = { version = "0.6", features = ["cors", "fs"] } +tower-http = { version = "0.7", features = ["cors", "fs"] } tracing = "0.1" url = "2" diff --git a/rs/moq-cli/README.md b/rs/moq-cli/README.md index 819e671b0..ad6152935 100644 --- a/rs/moq-cli/README.md +++ b/rs/moq-cli/README.md @@ -56,4 +56,3 @@ moq-cli accept --broadcast my-stream --format fmp4 | ffplay - - `avc3` raw H.264 Annex-B from stdin - `fmp4` fragmented MP4 from stdin -- `hls --playlist ` ingest from an HLS playlist diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index fb0432490..c372f7328 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -12,7 +12,11 @@ use subscribe::*; use web::*; use clap::{Parser, Subcommand}; +use std::net::SocketAddr; use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tower_http::cors::{Any, CorsLayer}; use url::Url; #[derive(Parser, Clone)] @@ -109,6 +113,51 @@ pub enum Command { #[command(flatten)] args: SubscribeArgs, }, + /// Import or export HLS / LL-HLS via a MoQ relay. + Hls { + /// The MoQ client configuration. + #[command(flatten)] + config: moq_native::ClientConfig, + + /// The URL of the MoQ server. + #[arg(long, alias = "relay", env = "MOQ_HLS_RELAY")] + url: Url, + + #[command(subcommand)] + command: HlsCommand, + }, +} + +#[derive(Subcommand, Clone)] +pub enum HlsCommand { + /// Serve HLS / LL-HLS over HTTP from MoQ broadcasts. + Export { + /// HTTP listener for the HLS endpoints. + #[arg(long, env = "MOQ_HLS_LISTEN", default_value = "[::]:8089")] + listen: SocketAddr, + + /// TLS certificates, keys, self-signed generation, and optional mTLS roots. + #[command(flatten)] + tls: moq_native::tls::Server, + + /// LL-HLS part target duration. + #[arg(long, env = "MOQ_HLS_PART_TARGET", default_value = "500ms", value_parser = humantime::parse_duration)] + part_target: Duration, + + /// Minimum duration of media kept in each rendition's sliding window. + #[arg(long, env = "MOQ_HLS_WINDOW", default_value = "16s", value_parser = humantime::parse_duration)] + window: Duration, + }, + /// Pull a remote HLS master/media playlist and publish it into MoQ. + Import { + /// Broadcast name to publish on the relay. + #[arg(long, alias = "name", env = "MOQ_HLS_BROADCAST")] + broadcast: String, + + /// Remote HLS playlist URL (http/https) or local file path. + #[arg(long, env = "MOQ_HLS_PLAYLIST")] + playlist: String, + }, } #[tokio::main] @@ -200,6 +249,22 @@ async fn main() -> anyhow::Result<()> { run_subscribe(client, url, broadcast, args).await } + Command::Hls { config, url, command } => { + let client = config.init()?; + + #[cfg(feature = "iroh")] + let client = client.with_iroh(iroh); + + match command { + HlsCommand::Export { + listen, + tls, + part_target, + window, + } => run_hls_export(client, url, listen, tls, part_target, window).await, + HlsCommand::Import { broadcast, playlist } => run_hls_import(client, url, broadcast, playlist).await, + } + } } } @@ -249,3 +314,123 @@ async fn run_announced_subscribe( Subscribe::new(consumer, catalog, args).run().await } + +async fn run_hls_export( + client: moq_native::Client, + url: Url, + listen: SocketAddr, + tls: moq_native::tls::Server, + part_target: Duration, + window: Duration, +) -> anyhow::Result<()> { + let subscriber = moq_net::Origin::random().produce(); + let consumer = subscriber.consume(); + let reconnect = client.with_subscriber(subscriber).reconnect(url.clone()); + + let config = moq_hls::export::Config { + part_target, + window, + ..Default::default() + }; + let server = moq_hls::Server::new(consumer, config); + let app = server + .router() + .layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any)); + + let tls = if tls.cert.is_empty() && tls.generate.is_empty() { + None + } else { + let alpn = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Some(tls.server_config(alpn)?) + }; + + let listener = moq_native::bind::tcp(listen)?; + + tracing::info!(%url, %listen, "serving HLS"); + + #[cfg(unix)] + let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); + + tokio::select! { + res = serve_hls(listener, app, tls) => res, + res = reconnect.closed() => res.map_err(Into::into), + _ = shutdown_signal() => Ok(()), + } +} + +async fn run_hls_import( + client: moq_native::Client, + url: Url, + broadcast: String, + playlist: String, +) -> anyhow::Result<()> { + warn_if_missing_format(&broadcast); + + let publisher = moq_net::Origin::random().produce(); + let reconnect = client.with_publisher(publisher.consume()).reconnect(url.clone()); + + let mut producer = moq_net::BroadcastInfo::new().produce(); + let consumer = producer.consume(); + // Held for the import's lifetime; dropping the guard unannounces the broadcast. + let _publish = publisher.publish_broadcast(&broadcast, consumer)?; + + let catalog = moq_mux::catalog::Producer::new(&mut producer)?; + let mut importer = moq_hls::import::Import::new(producer, catalog, moq_hls::import::Config::new(playlist))?; + + tracing::info!(%url, %broadcast, "importing HLS"); + + tokio::select! { + res = async { + importer.init().await?; + + #[cfg(unix)] + let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); + + importer.run().await + } => res.map_err(Into::into), + res = reconnect.closed() => res.map_err(Into::into), + _ = shutdown_signal() => Ok(()), + } +} + +async fn serve_hls( + listener: std::net::TcpListener, + app: axum::Router, + tls: Option>, +) -> anyhow::Result<()> { + let service = app.into_make_service(); + match tls { + Some(config) => { + let config = axum_server::tls_rustls::RustlsConfig::from_config(config); + axum_server::from_tcp_rustls(listener, config)?.serve(service).await?; + } + None => { + axum_server::from_tcp(listener)?.serve(service).await?; + } + } + Ok(()) +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + + let mut term = match signal(SignalKind::terminate()) { + Ok(term) => term, + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = term.recv() => {} + } + } + + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index dbe35d2af..296f44a47 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -60,9 +60,36 @@ pub(crate) struct BroadcastProducer { pub(crate) catalog: moq_mux::catalog::Producer, } +/// A whole-frame importer: a single codec track, or a container that may publish +/// several tracks. The format string picks which when the producer is created. +enum MediaDecoder { + // Boxed because the codec splitters/imports make this variant much larger than + // the (already boxed) container one. + Track(Box>), + Container(moq_mux::import::Container), +} + +impl MediaDecoder { + fn decode(&mut self, frame: &[u8], pts: Option) -> moq_mux::Result<()> { + match self { + Self::Track(t) => t.decode(frame, pts), + Self::Container(c) => c.decode(frame), + } + } + + fn finish(&mut self) -> moq_mux::Result<()> { + match self { + Self::Track(t) => t.finish(), + Self::Container(c) => c.finish(), + } + } +} + struct MediaProducer { - decoder: moq_mux::import::Track, - demand: moq_net::TrackDemand, + decoder: MediaDecoder, + /// `Some` for a single codec track, whose subscriber demand (name/used/unused) + /// is observable; `None` for a container that may publish several tracks. + demand: Option, } /// A byte-stream importer: a single codec track or a container that may publish @@ -201,15 +228,31 @@ impl MoqBroadcastProducer { let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or_else(|| MoqError::Closed)?; - let mut broadcast = state.broadcast.clone(); - let name = broadcast.unique_name(&format!(".{format}")); - let request = broadcast - .reserve_track(name) - .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; - let decoder = moq_mux::import::Track::new(request, state.catalog.clone(), &format, &init) - .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; - - let demand = decoder.demand(); + // A container may publish several tracks; a single codec fills one reserved + // track. Try the container first so a codec format doesn't reserve a stray + // track on the way to being recognized. + let (decoder, demand) = + match moq_mux::import::Container::new(state.broadcast.clone(), state.catalog.clone(), &format, &init) { + Ok(container) => (MediaDecoder::Container(container), None), + Err(moq_mux::Error::UnknownFormat(_)) => { + let mut broadcast = state.broadcast.clone(); + let name = broadcast.unique_name(&format!(".{format}")); + let request = broadcast + .reserve_track(name) + .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; + match moq_mux::import::Track::new(request, state.catalog.clone(), &format, &init) { + Ok(import) => { + let demand = import.demand(); + (MediaDecoder::Track(Box::new(import)), Some(demand)) + } + Err(moq_mux::Error::UnknownFormat(_)) => { + return Err(MoqError::Codec(format!("unknown format: {format}"))); + } + Err(err) => return Err(MoqError::Codec(format!("init failed: {err}"))), + } + } + Err(err) => return Err(MoqError::Codec(format!("init failed: {err}"))), + }; Ok(Arc::new(MoqMediaProducer { inner: std::sync::Mutex::new(Some(MediaProducer { decoder, demand })), @@ -235,13 +278,16 @@ impl MoqBroadcastProducer { // The importer accepts the request itself, which is where the track's timescale is set. let request = request.take()?; - let decoder = moq_mux::import::Track::new(request, state.catalog.clone(), &format, &init) + let import = moq_mux::import::Track::new(request, state.catalog.clone(), &format, &init) .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; - let demand = decoder.demand(); + let demand = import.demand(); Ok(Arc::new(MoqMediaProducer { - inner: std::sync::Mutex::new(Some(MediaProducer { decoder, demand })), + inner: std::sync::Mutex::new(Some(MediaProducer { + decoder: MediaDecoder::Track(Box::new(import)), + demand: Some(demand), + })), })) } @@ -526,14 +572,22 @@ impl MoqGroupProducer { #[uniffi::export] impl MoqMediaProducer { /// Return the name of the media track. + /// + /// Errors for a multi-track container source, which has no single track name. pub fn name(&self) -> Result { let _guard = crate::ffi::RUNTIME.enter(); let guard = self.inner.lock().unwrap(); let media = guard.as_ref().ok_or_else(|| MoqError::Closed)?; - Ok(media.demand.name().to_string()) + let demand = media + .demand + .as_ref() + .ok_or_else(|| MoqError::Codec("demand unavailable for a multi-track container".into()))?; + Ok(demand.name().to_string()) } /// Wait until this media track has at least one active consumer. + /// + /// Errors for a multi-track container source, which has no single demand. pub async fn used(&self) -> Result<(), MoqError> { let demand = self .inner @@ -542,7 +596,8 @@ impl MoqMediaProducer { .as_ref() .ok_or(MoqError::Closed)? .demand - .clone(); + .clone() + .ok_or_else(|| MoqError::Codec("demand unavailable for a multi-track container".into()))?; match crate::ffi::RUNTIME.spawn(async move { demand.used().await }).await { Ok(result) => result.map_err(Into::into), Err(e) if e.is_cancelled() => Err(MoqError::Cancelled), @@ -551,6 +606,8 @@ impl MoqMediaProducer { } /// Wait until this media track has no active consumers. + /// + /// Errors for a multi-track container source, which has no single demand. pub async fn unused(&self) -> Result<(), MoqError> { let demand = self .inner @@ -559,7 +616,8 @@ impl MoqMediaProducer { .as_ref() .ok_or(MoqError::Closed)? .demand - .clone(); + .clone() + .ok_or_else(|| MoqError::Codec("demand unavailable for a multi-track container".into()))?; match crate::ffi::RUNTIME.spawn(async move { demand.unused().await }).await { Ok(result) => result.map_err(Into::into), Err(e) if e.is_cancelled() => Err(MoqError::Cancelled), diff --git a/rs/moq-flate/Cargo.toml b/rs/moq-flate/Cargo.toml new file mode 100644 index 000000000..03926fc9d --- /dev/null +++ b/rs/moq-flate/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "moq-flate" +description = "Group-scoped DEFLATE: a stream of self-delimited frames sharing one compression window." +authors = ["Luke Curley "] +repository = "https://github.com/moq-dev/moq" +license = "MIT OR Apache-2.0" + +version = "0.1.0" +edition = "2024" +rust-version.workspace = true + +keywords = ["deflate", "compression", "streaming", "live"] +categories = ["compression"] + +[lib] +doctest = false + +[dependencies] +bytes = "1" +flate2 = { workspace = true } +thiserror = "2" diff --git a/rs/moq-flate/src/lib.rs b/rs/moq-flate/src/lib.rs new file mode 100644 index 000000000..ae6a6db96 --- /dev/null +++ b/rs/moq-flate/src/lib.rs @@ -0,0 +1,294 @@ +//! Group-scoped DEFLATE: a stream of self-delimited frames sharing one compression window. +//! +//! A sequence of frame payloads is compressed into a single raw DEFLATE ([RFC 1951]) stream, +//! sync-flushed at each frame boundary. Every frame is therefore self-delimited (byte-aligned, the +//! window retained) while later frames reuse the earlier ones as context, so a stream of similar +//! payloads (a snapshot followed by deltas, repeated records, log lines) compresses far better than +//! each payload alone. The [`Encoder`]/[`Decoder`] hold that shared window; create a fresh pair per +//! independent stream (in moq-net terms, per group). +//! +//! This is plain raw DEFLATE with a `Z_SYNC_FLUSH` after each frame, so any peer using the same +//! primitive (zlib's sync flush, the browser's `deflate-raw`) interoperates on the wire. There is no +//! length prefix: the caller is expected to frame each slice (moq-net already does). A small slice +//! can still inflate to far more than its own size, so [`Decoder::frame`] bounds each frame's output. +//! +//! A sync flush always ends in the 4-byte empty-block marker `00 00 ff ff`. That marker is fixed, so +//! [`Encoder::frame`] drops it from each slice and [`Decoder::frame`] re-appends it before inflating, +//! saving 4 bytes per frame. This is the same trick [RFC 7692] (permessage-deflate) uses for +//! WebSocket messages. +//! +//! ```ignore +//! let mut encoder = moq_flate::Encoder::new(); +//! let a = encoder.frame(b"the quick brown fox"); +//! let b = encoder.frame(b"the quick brown dog"); // smaller: reuses the window +//! +//! let mut decoder = moq_flate::Decoder::new(); +//! assert_eq!(decoder.frame(&a)?, &b"the quick brown fox"[..]); +//! assert_eq!(decoder.frame(&b)?, &b"the quick brown dog"[..]); +//! ``` +//! +//! [RFC 1951]: https://www.rfc-editor.org/rfc/rfc1951.html +//! [RFC 7692]: https://www.rfc-editor.org/rfc/rfc7692.html#section-7.2.1 + +use bytes::Bytes; +use flate2::{Compress, Decompress, FlushCompress, FlushDecompress, Status}; + +/// The default DEFLATE level ([`Encoder::new`]): zlib's own default, a good size/speed balance for +/// the small, repetitive payloads this targets. +pub const DEFAULT_LEVEL: u32 = 6; + +/// The default per-frame decompressed-size cap ([`Decoder::new`]): 64 MiB. +pub const DEFAULT_MAX_FRAME_SIZE: u64 = 64 * 1024 * 1024; + +/// The trailing bytes of a DEFLATE sync flush, stripped on the wire and re-appended to decode. +const SYNC_FLUSH_TAIL: [u8; 4] = [0x00, 0x00, 0xff, 0xff]; + +/// Scratch buffer size for the streaming (de)compress loops. +const CHUNK: usize = 8 * 1024; + +/// Errors produced while decoding a frame. +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Error { + /// A frame could not be decoded (malformed or truncated stream, or fed out of order). + #[error("decompression failed")] + Decompress, + + /// A frame's decompressed size exceeded the configured limit (zip-bomb guard). + #[error("decompressed frame exceeded {0} bytes")] + TooLarge(u64), +} + +/// A [`Result`](std::result::Result) using this crate's [`Error`]. +pub type Result = std::result::Result; + +/// Encodes a stream's frame payloads into one shared DEFLATE window, one self-delimited slice per +/// frame. Hold one per stream; create a fresh one for each independent stream. +pub struct Encoder(Compress); + +impl Encoder { + /// Start a fresh encoder with a cold window at [`DEFAULT_LEVEL`]. + pub fn new() -> Self { + Self::with_level(DEFAULT_LEVEL) + } + + /// Start a fresh encoder with a cold window at the given DEFLATE level (`0..=9`; higher is + /// smaller and slower). Values above `9` are clamped. + pub fn with_level(level: u32) -> Self { + // `false`: raw DEFLATE, no zlib header/trailer, matching `deflate-raw` on the browser side. + Self(Compress::new(flate2::Compression::new(level.min(9)), false)) + } + + /// Compress the next frame's `payload`, returning its slice of the stream: the DEFLATE bytes minus + /// the fixed sync-flush marker. Empty in yields empty out. Later frames reuse earlier ones as + /// context, so slices must be produced (and later decoded) in frame order. + pub fn frame(&mut self, payload: &[u8]) -> Bytes { + if payload.is_empty() { + return Bytes::new(); + } + + let mut out = Vec::with_capacity(payload.len() / 2 + 16); + let mut tmp = [0u8; CHUNK]; + let mut input = payload; + + // Drive the stream with a sync flush so this frame's slice is self-delimited (byte-aligned, + // window retained). The classic zlib loop: keep going while the output buffer fills up. + loop { + let before_in = self.0.total_in(); + let before_out = self.0.total_out(); + self.0.compress(input, &mut tmp, FlushCompress::Sync).expect("deflate"); + let consumed = (self.0.total_in() - before_in) as usize; + let produced = (self.0.total_out() - before_out) as usize; + out.extend_from_slice(&tmp[..produced]); + input = &input[consumed..]; + if produced < tmp.len() { + break; + } + } + + // Drop the fixed sync-flush marker; the decoder re-appends it (see the module docs). + debug_assert!( + out.ends_with(&SYNC_FLUSH_TAIL), + "a sync flush must end in the deflate marker" + ); + out.truncate(out.len() - SYNC_FLUSH_TAIL.len()); + Bytes::from(out) + } +} + +impl Default for Encoder { + fn default() -> Self { + Self::new() + } +} + +/// Decodes a stream's frame slices back into the original payloads. Hold one per stream; feed slices +/// in frame order (each frame builds on the earlier ones). +pub struct Decoder { + inner: Decompress, + max_frame_size: u64, +} + +impl Decoder { + /// Start a fresh decoder with a cold window and the [`DEFAULT_MAX_FRAME_SIZE`] cap. + pub fn new() -> Self { + Self::with_max_frame_size(DEFAULT_MAX_FRAME_SIZE) + } + + /// Start a fresh decoder with a cold window and a custom per-frame decompressed-size cap. + /// + /// A malicious or buggy peer could send a tiny slice that inflates hugely, so [`frame`](Self::frame) + /// stops and returns [`Error::TooLarge`] once a single frame's output would exceed `max_frame_size`. + pub fn with_max_frame_size(max_frame_size: u64) -> Self { + // `false`: raw DEFLATE, matching the encoder. + Self { + inner: Decompress::new(false), + max_frame_size, + } + } + + /// Decompress the next frame's `slice` back into its payload. + /// + /// An empty slice yields an empty payload. Returns [`Error::TooLarge`] if the frame inflates past + /// the configured cap (checked as output is produced, not from any declared size), and + /// [`Error::Decompress`] on malformed input. + pub fn frame(&mut self, slice: &[u8]) -> Result { + if slice.is_empty() { + return Ok(Bytes::new()); + } + + let mut out = Vec::new(); + let mut tmp = [0u8; CHUNK]; + + // Feed the wire slice followed by the re-appended sync-flush marker, which delimits the frame + // and flushes its last bytes out of the inflate buffer. + for segment in [slice, &SYNC_FLUSH_TAIL] { + let mut input = segment; + loop { + let before_in = self.inner.total_in(); + let before_out = self.inner.total_out(); + let status = self + .inner + .decompress(input, &mut tmp, FlushDecompress::Sync) + .map_err(|_| Error::Decompress)?; + let consumed = (self.inner.total_in() - before_in) as usize; + let produced = (self.inner.total_out() - before_out) as usize; + // Bound the inflated output as it is produced; a tiny slice can expand enormously. + if out.len() as u64 + produced as u64 > self.max_frame_size { + return Err(Error::TooLarge(self.max_frame_size)); + } + out.extend_from_slice(&tmp[..produced]); + input = &input[consumed..]; + + // Move to the next segment once this one is drained and the buffer wasn't saturated. The + // no-progress guard avoids spinning when the marker needs no further output. + if matches!(status, Status::StreamEnd) || (input.is_empty() && produced < tmp.len()) { + break; + } + if consumed == 0 && produced == 0 { + break; + } + } + } + + Ok(Bytes::from(out)) + } +} + +impl Default for Decoder { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod test { + use super::*; + + /// Round-trip a sequence of frames through an encoder/decoder pair. + fn roundtrip(frames: &[&[u8]]) -> Vec> { + let mut enc = Encoder::new(); + let slices: Vec = frames.iter().map(|f| enc.frame(f)).collect(); + + let mut dec = Decoder::new(); + slices.iter().map(|s| dec.frame(s).unwrap().to_vec()).collect() + } + + #[test] + fn stream_roundtrip() { + let frames: &[&[u8]] = &[b"the quick brown fox", b"the quick brown dog", b"the lazy fox"]; + let got = roundtrip(frames); + for (a, b) in frames.iter().zip(&got) { + assert_eq!(*a, b.as_slice()); + } + } + + #[test] + fn empty_frames_roundtrip() { + assert!(Encoder::new().frame(b"").is_empty()); + assert!(Decoder::new().frame(b"").unwrap().is_empty()); + } + + #[test] + fn cross_frame_context_shrinks() { + // A later frame identical to an earlier one compresses to far fewer bytes once the window + // holds the earlier copy: this is the whole point of a shared stream. + let payload = b"Media over QUIC delivers real-time latency at massive scale.".repeat(6); + let mut enc = Encoder::new(); + let first = enc.frame(&payload); + let second = enc.frame(&payload); + assert!( + second.len() < first.len(), + "repeat frame {} should be smaller than first {}", + second.len(), + first.len() + ); + } + + #[test] + fn frame_larger_than_chunk_roundtrips() { + // High-entropy data barely compresses, so its slice exceeds the streaming `CHUNK` scratch + // buffer and the (de)compress loops must iterate. Verify it still round-trips byte for byte. + let mut state: u64 = 0x9E37_79B9_7F4A_7C15; + let payload: Vec = (0..64 * 1024) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state >> 56) as u8 + }) + .collect(); + + let mut enc = Encoder::new(); + let slice = enc.frame(&payload); + assert!(slice.len() > CHUNK, "slice {} should exceed CHUNK {CHUNK}", slice.len()); + + let mut dec = Decoder::new(); + assert_eq!(dec.frame(&slice).unwrap(), Bytes::from(payload)); + } + + #[test] + fn decompress_rejects_garbage() { + let mut dec = Decoder::new(); + assert_eq!(dec.frame(b"not a deflate stream at all"), Err(Error::Decompress)); + } + + #[test] + fn enforces_max_frame_size() { + // A tiny slice of a highly compressible payload inflates past a small cap. + let payload = vec![0u8; 1024]; + let slice = Encoder::new().frame(&payload); + + let mut dec = Decoder::with_max_frame_size(512); + assert_eq!(dec.frame(&slice), Err(Error::TooLarge(512))); + } + + #[test] + fn custom_level_roundtrips() { + let payload = b"compress me at maximum effort".repeat(8); + let mut enc = Encoder::with_level(9); + let slice = enc.frame(&payload); + let mut dec = Decoder::new(); + assert_eq!(dec.frame(&slice).unwrap(), Bytes::from(payload)); + } +} diff --git a/rs/moq-gst/src/sink/pad.rs b/rs/moq-gst/src/sink/pad.rs index 2214ef43e..bd3e245c2 100644 --- a/rs/moq-gst/src/sink/pad.rs +++ b/rs/moq-gst/src/sink/pad.rs @@ -115,6 +115,10 @@ impl Pad { let channels: i32 = structure.get("channels").context("Opus caps missing channels")?; let rate: i32 = structure.get("rate").context("Opus caps missing rate")?; ensure!(channels > 0, "Opus caps has non-positive channel count {channels}"); + ensure!( + channels <= 2, + "multichannel Opus is not supported yet (channels={channels})" + ); ensure!(rate > 0, "Opus caps has non-positive sample rate {rate}"); let config = moq_mux::codec::opus::Config { sample_rate: rate as u32, diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index ceb464835..7423a2614 100644 --- a/rs/moq-hls/Cargo.toml +++ b/rs/moq-hls/Cargo.toml @@ -53,7 +53,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false, optional = true } thiserror = "2" tokio = { workspace = true, features = ["full"] } -tower-http = { version = "0.6", features = ["cors"], optional = true } +tower-http = { version = "0.7", features = ["cors"], optional = true } tracing = "0.1" url = "2" diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index 8cbeac345..aaf984049 100644 --- a/rs/moq-json/Cargo.toml +++ b/rs/moq-json/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.4" +version = "0.1.0" edition = "2024" rust-version.workspace = true @@ -17,10 +17,17 @@ doctest = false [dependencies] bytes = "1" -flate2 = { workspace = true } json-patch = "4" kio = { workspace = true } +moq-flate = { workspace = true } moq-net = { workspace = true } serde = { workspace = true } serde_json = "1" thiserror = "2" + +[dev-dependencies] +criterion = "0.8" + +[[bench]] +name = "codec" +harness = false diff --git a/rs/moq-json/benches/codec.rs b/rs/moq-json/benches/codec.rs new file mode 100644 index 000000000..63719913c --- /dev/null +++ b/rs/moq-json/benches/codec.rs @@ -0,0 +1,367 @@ +//! CPU benchmarks for the raw codec operations behind `moq-json`, with no Producer/Consumer or +//! moq-net framing in the loop. Each measures one step of the per-delta pipeline: +//! +//! 1. `encode_patch` - generate a merge patch from the old value and the new one ([`diff`]). +//! 2. `decode_patch` - apply a merge patch to reconstruct the new value (`json_patch::merge`), with +//! a consuming variant (`merge_owned`) for comparison. +//! 3. `deflate` - compress a delta into a warm DEFLATE window. +//! 4. `inflate` - decompress a delta from a warm DEFLATE window. +//! 5. `producer` - the full producer step: encode patch, serialize, deflate. +//! 6. `consumer` - the full consumer step: inflate, parse, merge. +//! +//! Run with `cargo bench -p moq-json`. + +use std::hint::black_box; + +use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use moq_flate::{Decoder, Encoder}; +use moq_json::diff; +use serde_json::{Map, Value, json}; + +/// One second of telemetry: a big static core plus a few moving numbers. Most fields change a little +/// each tick, so the delta is small but touches much of the document. +fn telemetry(tick: u64) -> Value { + let t = tick as f64; + let lat = 37.7749 + (t * 0.0001).sin() * 0.01; + let lon = -122.4194 + (t * 0.0001).cos() * 0.01; + + json!({ + "device": { + "id": "veh-4417-a2", + "model": "Sentinel X2", + "firmware": "4.18.2-rc1", + "serial": "SNX2-0000-4417-A2C9", + "region": "us-west-2", + "fleet": "logistics-prod", + "tags": ["cold-chain", "long-haul", "priority"], + }, + "config": { + "sample_hz": 1, + "upload_hz": 1, + "geofence": "bay-area", + "thresholds": { "temp_c": 8.0, "humidity": 85, "shock_g": 3.5, "battery_pct": 15 }, + "contacts": ["ops@example.com", "fleet@example.com"], + }, + "ts": 1_700_000_000 + tick, + "uptime_s": tick, + "location": { + "lat": (lat * 1e6).round() / 1e6, + "lon": (lon * 1e6).round() / 1e6, + "alt_m": 12 + (tick % 5), + "heading": (tick * 7) % 360, + "speed_kph": 40 + (tick % 25), + "fix": "3d", + "sats": 9 + (tick % 3), + }, + "sensors": { + "temp_c": ((4.0 + (t * 0.05).sin() * 1.5) * 100.0).round() / 100.0, + "humidity": 60 + (tick % 10), + "shock_g": (((t * 0.3).sin().abs()) * 100.0).round() / 100.0, + "door_open": tick % 30 == 0, + }, + "power": { + "battery_pct": 100 - (tick / 6) % 100, + "charging": false, + "voltage_mv": 12_400 - (tick % 50) as i64, + "current_ma": 850 + (tick % 120) as i64, + }, + "network": { + "rssi_dbm": -70 - (tick % 15) as i64, + "type": "lte", + "bytes_up": 1_024 * tick, + "bytes_down": 256 * tick, + "latency_ms": 35 + (tick % 40), + }, + "counters": { + "events": tick, + "errors": tick / 50, + "reconnects": tick / 120, + }, + }) +} + +/// A large mostly-static document: a big config blob that never changes plus a few counters that +/// tick. The delta is tiny relative to the document. +fn big_static(tick: u64) -> Value { + let routes: Vec = (0..80) + .map(|i| { + json!({ + "id": format!("route-{i:04}"), + "cidr": format!("10.{}.{}.0/24", i / 16, i % 16), + "gateway": format!("10.0.{i}.1"), + "metric": 100 + i, + "enabled": true, + "tags": ["prod", "egress", "monitored"], + }) + }) + .collect(); + + json!({ + "meta": { "version": "9.2.1", "node": "edge-router-77", "region": "us-east-1" }, + "routes": routes, + "counters": { + "packets_in": 1_000_000 + tick * 137, + "packets_out": 990_000 + tick * 131, + "errors": tick / 7, + "uptime_s": tick, + }, + }) +} + +/// RFC 7396 merge that consumes the patch, moving values into the target instead of cloning them like +/// `json_patch::merge` (which takes `&Value`). Used to test whether a consuming merge decodes faster. +fn merge_owned(target: &mut Value, patch: Value) { + let Value::Object(patch) = patch else { + *target = patch; + return; + }; + if !target.is_object() { + *target = Value::Object(Map::new()); + } + let map = target.as_object_mut().unwrap(); + for (key, value) in patch { + if value.is_null() { + map.remove(&key); + } else { + merge_owned(map.entry(key).or_insert(Value::Null), value); + } + } +} + +/// One workload reduced to a single old -> new transition and the artifacts each op needs: the patch +/// value, the serialized snapshot and patch, and the compressed snapshot/delta slices (the delta is +/// compressed against a window already holding the snapshot, matching the real per-group stream). +struct Fixture { + name: &'static str, + old: Value, + new: Value, + patch: Value, + snapshot_bytes: Vec, + patch_bytes: Vec, + snapshot_slice: Vec, + delta_slice: Vec, + // The full new value compressed against a window already holding the old snapshot: what a + // snapshot-only stream (no merge patch) would send each tick, the fair baseline for the delta path. + new_slice: Vec, +} + +impl Fixture { + fn new(name: &'static str, make: fn(u64) -> Value) -> Self { + let old = make(0); + let new = make(1); + let patch = diff(&old, &new).patch; + let snapshot_bytes = serde_json::to_vec(&old).unwrap(); + let patch_bytes = serde_json::to_vec(&patch).unwrap(); + let new_bytes = serde_json::to_vec(&new).unwrap(); + + // Snapshot then delta (the merge-patch stream). + let mut enc = Encoder::new(); + let snapshot_slice = enc.frame(&snapshot_bytes).to_vec(); + let delta_slice = enc.frame(&patch_bytes).to_vec(); + + // Snapshot then the full new snapshot again (the snapshot-only stream). + let mut enc = Encoder::new(); + enc.frame(&snapshot_bytes); + let new_slice = enc.frame(&new_bytes).to_vec(); + + Self { + name, + old, + new, + patch, + snapshot_bytes, + patch_bytes, + snapshot_slice, + delta_slice, + new_slice, + } + } + + /// A DEFLATE encoder warmed with the snapshot, ready to compress the delta as the next frame. + fn warm_encoder(&self) -> Encoder { + let mut enc = Encoder::new(); + enc.frame(&self.snapshot_bytes); + enc + } + + /// A DEFLATE decoder warmed with the snapshot, ready to decompress the delta as the next frame. + fn warm_decoder(&self) -> Decoder { + let mut dec = Decoder::new(); + dec.frame(&self.snapshot_slice).unwrap(); + dec + } +} + +fn fixtures() -> Vec { + vec![ + Fixture::new("telemetry", telemetry), + Fixture::new("big_static", big_static), + ] +} + +/// 1. Generate a merge patch from the old and new values. +fn encode_patch(c: &mut Criterion) { + let mut group = c.benchmark_group("encode_patch"); + for f in &fixtures() { + group.throughput(Throughput::Bytes(f.snapshot_bytes.len() as u64)); + group.bench_with_input(BenchmarkId::from_parameter(f.name), f, |b, f| { + b.iter(|| black_box(diff(&f.old, &f.new))); + }); + } + group.finish(); +} + +/// 2. Apply a merge patch to reconstruct the new value, json_patch (borrowing) vs consuming merge. +fn decode_patch(c: &mut Criterion) { + let mut group = c.benchmark_group("decode_patch"); + for f in &fixtures() { + group.throughput(Throughput::Bytes(f.snapshot_bytes.len() as u64)); + group.bench_with_input(BenchmarkId::new("json_patch", f.name), f, |b, f| { + b.iter_batched( + || f.old.clone(), + |mut current| { + json_patch::merge(&mut current, &f.patch); + black_box(current); + }, + BatchSize::SmallInput, + ); + }); + group.bench_with_input(BenchmarkId::new("merge_owned", f.name), f, |b, f| { + b.iter_batched( + || (f.old.clone(), f.patch.clone()), + |(mut current, patch)| { + merge_owned(&mut current, patch); + black_box(current); + }, + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +/// 3. Compress a delta into a warm DEFLATE window. +fn deflate(c: &mut Criterion) { + let mut group = c.benchmark_group("deflate"); + for f in &fixtures() { + group.throughput(Throughput::Bytes(f.patch_bytes.len() as u64)); + group.bench_with_input(BenchmarkId::from_parameter(f.name), f, |b, f| { + b.iter_batched( + || f.warm_encoder(), + |mut enc| black_box(enc.frame(&f.patch_bytes)), + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +/// 4. Decompress a delta from a warm DEFLATE window. +fn inflate(c: &mut Criterion) { + let mut group = c.benchmark_group("inflate"); + for f in &fixtures() { + group.throughput(Throughput::Bytes(f.patch_bytes.len() as u64)); + group.bench_with_input(BenchmarkId::from_parameter(f.name), f, |b, f| { + b.iter_batched( + || f.warm_decoder(), + |mut dec| black_box(dec.frame(&f.delta_slice).unwrap()), + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +/// The marshal each path pays per tick: a full `to_vec` of the document (snapshot-only) vs a `to_vec` +/// of just the patch (delta). The diff already walks the whole document, so this is the extra cost +/// the snapshot path carries that the delta path avoids. +fn marshal(c: &mut Criterion) { + let mut group = c.benchmark_group("marshal"); + for f in &fixtures() { + group.bench_with_input(BenchmarkId::new("full", f.name), f, |b, f| { + b.iter(|| black_box(serde_json::to_vec(&f.new).unwrap())); + }); + group.bench_with_input(BenchmarkId::new("patch", f.name), f, |b, f| { + b.iter(|| black_box(serde_json::to_vec(&f.patch).unwrap())); + }); + } + group.finish(); +} + +/// The full producer step, head to head: the delta path (diff + serialize patch + deflate) vs the +/// snapshot-only path (serialize the whole document + deflate it), both into a warm window. The +/// snapshot path is charged for the full marshal it pays every tick. +fn producer(c: &mut Criterion) { + let mut group = c.benchmark_group("producer"); + for f in &fixtures() { + group.throughput(Throughput::Bytes(f.snapshot_bytes.len() as u64)); + group.bench_with_input(BenchmarkId::new("merge", f.name), f, |b, f| { + b.iter_batched( + || f.warm_encoder(), + |mut enc| { + let patch = diff(&f.old, &f.new).patch; + let bytes = serde_json::to_vec(&patch).unwrap(); + black_box(enc.frame(&bytes)); + }, + BatchSize::SmallInput, + ); + }); + group.bench_with_input(BenchmarkId::new("snapshot", f.name), f, |b, f| { + b.iter_batched( + || f.warm_encoder(), + |mut enc| { + let bytes = serde_json::to_vec(&f.new).unwrap(); + black_box(enc.frame(&bytes)); + }, + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +/// The full consumer step, head to head: the delta path (inflate + parse patch + merge) vs the +/// snapshot-only path (inflate + parse the whole document). The snapshot path re-parses the entire +/// document every tick; the delta path parses only the patch. +fn consumer(c: &mut Criterion) { + let mut group = c.benchmark_group("consumer"); + for f in &fixtures() { + group.throughput(Throughput::Bytes(f.snapshot_bytes.len() as u64)); + group.bench_with_input(BenchmarkId::new("merge", f.name), f, |b, f| { + b.iter_batched( + || (f.warm_decoder(), f.old.clone()), + |(mut dec, mut current)| { + let plain = dec.frame(&f.delta_slice).unwrap(); + let patch: Value = serde_json::from_slice(&plain).unwrap(); + json_patch::merge(&mut current, &patch); + black_box(current); + }, + BatchSize::SmallInput, + ); + }); + group.bench_with_input(BenchmarkId::new("snapshot", f.name), f, |b, f| { + b.iter_batched( + || f.warm_decoder(), + |mut dec| { + let plain = dec.frame(&f.new_slice).unwrap(); + let value: Value = serde_json::from_slice(&plain).unwrap(); + black_box(value); + }, + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +criterion_group!( + benches, + encode_patch, + decode_patch, + deflate, + inflate, + marshal, + producer, + consumer +); +criterion_main!(benches); diff --git a/rs/moq-json/src/compression.rs b/rs/moq-json/src/compression.rs deleted file mode 100644 index 5bc6a9303..000000000 --- a/rs/moq-json/src/compression.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! Group-scoped DEFLATE compression for the JSON frame stream. -//! -//! Within a group the frame payloads form a single raw DEFLATE ([RFC 1951]) stream, sync-flushed -//! at each frame boundary so every frame carries its own self-delimited slice while later frames -//! reuse the earlier ones as context (a snapshot followed by deltas compresses far better than -//! each frame alone). The [`Encoder`]/[`Decoder`] hold that per-group state; both are recreated at -//! every group boundary. -//! -//! This is plain raw DEFLATE with a `Z_SYNC_FLUSH` after each frame, so a browser (`@moq/json`) -//! peer interoperates on the wire using the same primitive (zlib's sync flush). moq-net already -//! frames each slice, so there's no length prefix. A small slice can still inflate to far more than -//! its own size, so [`Decoder::frame`] bounds each frame's output at [`MAX_DECOMPRESSED_FRAME`]. -//! -//! A sync flush always ends in the 4-byte empty-block marker `00 00 ff ff`. That marker is fixed, -//! so [`Encoder::frame`] drops it from each slice and [`Decoder::frame`] re-appends it before -//! inflating, saving 4 bytes per frame. This is the same trick [RFC 7692] (permessage-deflate) -//! uses for WebSocket messages. -//! -//! [RFC 1951]: https://www.rfc-editor.org/rfc/rfc1951.html -//! [RFC 7692]: https://www.rfc-editor.org/rfc/rfc7692.html#section-7.2.1 - -use bytes::Bytes; -use flate2::{Compress, Decompress, FlushCompress, FlushDecompress, Status}; - -use crate::{Error, Result}; - -/// DEFLATE level for the frame stream: zlib's own default, a good size/speed balance for the small, -/// repetitive payloads this targets. -const LEVEL: u32 = 6; - -/// The trailing bytes of a DEFLATE sync flush, stripped on the wire and re-appended to decode. -const SYNC_FLUSH_TAIL: [u8; 4] = [0x00, 0x00, 0xff, 0xff]; - -/// Maximum decompressed size of a single frame. -/// -/// A malicious publisher could otherwise send a tiny slice that inflates hugely, so -/// [`Decoder::frame`] stops and returns [`Error::TooLarge`] rather than allocating without limit. -const MAX_DECOMPRESSED_FRAME: u64 = 64 * 1024 * 1024; - -/// Scratch buffer size for the streaming (de)compress loops. -const CHUNK: usize = 8 * 1024; - -/// Encodes a group's frame payloads into one shared DEFLATE stream, one self-delimited slice per -/// frame. Hold one per group; the stream is recreated at each group boundary. -pub(crate) struct Encoder(Compress); - -impl Encoder { - /// Start a fresh per-group encoder with a cold window. - pub(crate) fn new() -> Self { - // `false`: raw DEFLATE, no zlib header/trailer, matching `deflate-raw` on the browser side. - Self(Compress::new(flate2::Compression::new(LEVEL), false)) - } - - /// Compress the next frame's `payload`, returning its slice of the group stream: the DEFLATE - /// bytes minus the fixed sync-flush marker. Empty in yields empty out. Later frames reuse earlier - /// ones as context, so slices must be produced (and later decoded) in frame order. - pub(crate) fn frame(&mut self, payload: &[u8]) -> Bytes { - if payload.is_empty() { - return Bytes::new(); - } - - let mut out = Vec::with_capacity(payload.len() / 2 + 16); - let mut tmp = [0u8; CHUNK]; - let mut input = payload; - - // Drive the stream with a sync flush so this frame's slice is self-delimited (byte-aligned, - // window retained). The classic zlib loop: keep going while the output buffer fills up. - loop { - let before_in = self.0.total_in(); - let before_out = self.0.total_out(); - self.0.compress(input, &mut tmp, FlushCompress::Sync).expect("deflate"); - let consumed = (self.0.total_in() - before_in) as usize; - let produced = (self.0.total_out() - before_out) as usize; - out.extend_from_slice(&tmp[..produced]); - input = &input[consumed..]; - if produced < tmp.len() { - break; - } - } - - // Drop the fixed sync-flush marker; the decoder re-appends it (see the module docs). - debug_assert!( - out.ends_with(&SYNC_FLUSH_TAIL), - "a sync flush must end in the deflate marker" - ); - out.truncate(out.len() - SYNC_FLUSH_TAIL.len()); - Bytes::from(out) - } -} - -/// Decodes a group's frame slices back into the original payloads. Hold one per group; feed slices -/// in frame order (each frame builds on the earlier ones). -pub(crate) struct Decoder(Decompress); - -impl Decoder { - /// Start a fresh per-group decoder with a cold window. - pub(crate) fn new() -> Self { - // `false`: raw DEFLATE, matching the encoder. - Self(Decompress::new(false)) - } - - /// Decompress the next frame's `slice` back into its payload. - /// - /// An empty slice yields an empty payload. Returns [`Error::TooLarge`] if the frame inflates past - /// the per-frame bound (checked as output is produced, not from any declared size), and - /// [`Error::Decompress`] on malformed input. - pub(crate) fn frame(&mut self, slice: &[u8]) -> Result { - if slice.is_empty() { - return Ok(Bytes::new()); - } - - let mut out = Vec::new(); - let mut tmp = [0u8; CHUNK]; - - // Feed the wire slice followed by the re-appended sync-flush marker, which delimits the frame - // and flushes its last bytes out of the inflate buffer. - for segment in [slice, &SYNC_FLUSH_TAIL] { - let mut input = segment; - loop { - let before_in = self.0.total_in(); - let before_out = self.0.total_out(); - let status = self - .0 - .decompress(input, &mut tmp, FlushDecompress::Sync) - .map_err(|_| Error::Decompress)?; - let consumed = (self.0.total_in() - before_in) as usize; - let produced = (self.0.total_out() - before_out) as usize; - // Bound the inflated output as it is produced; a tiny slice can expand enormously. - if out.len() as u64 + produced as u64 > MAX_DECOMPRESSED_FRAME { - return Err(Error::TooLarge(MAX_DECOMPRESSED_FRAME)); - } - out.extend_from_slice(&tmp[..produced]); - input = &input[consumed..]; - - // Move to the next segment once this one is drained and the buffer wasn't saturated. The - // no-progress guard avoids spinning when the marker needs no further output. - if matches!(status, Status::StreamEnd) || (input.is_empty() && produced < tmp.len()) { - break; - } - if consumed == 0 && produced == 0 { - break; - } - } - } - - Ok(Bytes::from(out)) - } -} - -#[cfg(test)] -mod test { - use super::*; - - /// Round-trip a sequence of frames through a group encoder/decoder pair. - fn roundtrip(frames: &[&[u8]]) -> Vec> { - let mut enc = Encoder::new(); - let slices: Vec = frames.iter().map(|f| enc.frame(f)).collect(); - - let mut dec = Decoder::new(); - slices.iter().map(|s| dec.frame(s).unwrap().to_vec()).collect() - } - - #[test] - fn group_roundtrip() { - let frames: &[&[u8]] = &[b"the quick brown fox", b"the quick brown dog", b"the lazy fox"]; - let got = roundtrip(frames); - for (a, b) in frames.iter().zip(&got) { - assert_eq!(*a, b.as_slice()); - } - } - - #[test] - fn empty_frames_roundtrip() { - assert!(Encoder::new().frame(b"").is_empty()); - assert!(Decoder::new().frame(b"").unwrap().is_empty()); - } - - #[test] - fn cross_frame_context_shrinks() { - // A later frame identical to an earlier one compresses to far fewer bytes once the window - // holds the earlier copy: this is the whole point of a shared per-group stream. - let payload = b"Media over QUIC delivers real-time latency at massive scale.".repeat(6); - let mut enc = Encoder::new(); - let first = enc.frame(&payload); - let second = enc.frame(&payload); - assert!( - second.len() < first.len(), - "repeat frame {} should be smaller than first {}", - second.len(), - first.len() - ); - } - - #[test] - fn frame_larger_than_chunk_roundtrips() { - // High-entropy data barely compresses, so its slice exceeds the streaming `CHUNK` scratch - // buffer and the (de)compress loops must iterate. Verify it still round-trips byte for byte. - let mut state: u64 = 0x9E37_79B9_7F4A_7C15; - let payload: Vec = (0..64 * 1024) - .map(|_| { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - (state >> 56) as u8 - }) - .collect(); - - let mut enc = Encoder::new(); - let slice = enc.frame(&payload); - assert!(slice.len() > CHUNK, "slice {} should exceed CHUNK {CHUNK}", slice.len()); - - let mut dec = Decoder::new(); - assert_eq!(dec.frame(&slice).unwrap(), Bytes::from(payload)); - } - - #[test] - fn decompress_rejects_garbage() { - let mut dec = Decoder::new(); - assert!(matches!( - dec.frame(b"not a deflate stream at all"), - Err(Error::Decompress) - )); - } -} diff --git a/rs/moq-json/src/diff.rs b/rs/moq-json/src/diff.rs index 117c5afe2..d07b1417e 100644 --- a/rs/moq-json/src/diff.rs +++ b/rs/moq-json/src/diff.rs @@ -1,67 +1,572 @@ +//! Generate an [RFC 7396](https://www.rfc-editor.org/rfc/rfc7396.html) JSON Merge Patch directly +//! from a value, diffing it against the previously published value as it is serialized. +//! +//! A serde [`Serializer`] walks the new value and compares each field against the corresponding +//! node of the old [`Value`], so unchanged scalars and subtrees cost only a comparison (no +//! allocation) and only changed nodes are built into the patch. This avoids materializing a full +//! `Value` tree for the new value just to diff two trees. + +use std::cell::Cell; +use std::collections::HashSet; + +use serde::Serialize; +use serde::ser::{Impossible, SerializeMap, SerializeSeq, SerializeStruct, Serializer}; use serde_json::{Map, Value}; -/// The result of diffing two JSON values into an RFC 7396 merge patch. +/// The result of diffing a value into an RFC 7396 merge patch. pub struct Diff { /// A merge patch that transforms the old value into the new one. pub patch: Value, - /// Set when the change can't be faithfully expressed as a merge patch, so the caller - /// should publish a full snapshot instead. This happens when a value is set to JSON null, - /// which merge patch reads as a key deletion. Arrays are fine: merge patch replaces them - /// wholesale, which is still typically smaller than a full snapshot. + /// Set when the change can't be faithfully expressed as a merge patch, so the caller should + /// publish a full snapshot instead. This happens when a value is set to JSON null, which merge + /// patch reads as a key deletion, or when the root is not an object. Arrays are fine: merge patch + /// replaces them wholesale, which is still typically smaller than a full snapshot. pub forced_snapshot: bool, } /// Generate an RFC 7396 merge patch transforming `old` into `new`. /// -/// Only object roots produce a recursive patch; any other root forces a snapshot. -pub fn diff(old: &Value, new: &Value) -> Diff { - if let (Value::Object(old), Value::Object(new)) = (old, new) { - let mut patch = Map::new(); - let mut forced = false; - diff_objects(old, new, &mut patch, &mut forced); - Diff { - patch: Value::Object(patch), - forced_snapshot: forced, +/// Only object roots produce a recursive patch; any other root forces a snapshot. A merge patch that +/// would delete a key it shouldn't (a value genuinely set to null) also forces a snapshot. +pub fn diff(old: &Value, new: &T) -> Diff { + let forced = Cell::new(false); + let node = new.serialize(Differ { + baseline: old, + forced: &forced, + }); + + match node { + // No field differed: an empty patch. A null somewhere may still have forced a snapshot. + Ok(Node::Same) => Diff { + patch: Value::Object(Map::new()), + forced_snapshot: forced.get(), + }, + // A non-object patch (or non-object baseline) can't be a recursive merge patch, so force a + // snapshot for non-object roots. + Ok(Node::Diff(patch)) => { + let non_object_root = !patch.is_object() || !old.is_object(); + Diff { + patch, + forced_snapshot: forced.get() || non_object_root, + } } - } else { - Diff { - patch: new.clone(), + // A value that isn't representable as JSON (e.g. a non-string map key) can't be diffed. Fall + // back to a snapshot; the caller's own serialization surfaces the real error if there is one. + Err(_) => Diff { + patch: Value::Object(Map::new()), forced_snapshot: true, + }, + } +} + +/// One node's verdict from the diffing serializer. +enum Node { + /// Equal to the baseline; nothing to emit. + Same, + /// Differs; the new value to splice into the patch. + Diff(Value), +} + +const NULL: Value = Value::Null; + +/// Serializer that diffs `T` against `baseline` and yields a merge patch. `forced` is set if a +/// genuine null is emitted (merge patch can't represent it, so the caller must snapshot). +#[derive(Copy, Clone)] +struct Differ<'a> { + baseline: &'a Value, + forced: &'a Cell, +} + +impl<'a> Differ<'a> { + /// The baseline child for `key` and whether the baseline actually had that key (a missing key + /// means the field is an addition, which `MapDiff` uses to keep deletion detection cheap). + fn child(&self, key: &str) -> (Differ<'a>, bool) { + let (baseline, existed) = match self.baseline { + Value::Object(m) => match m.get(key) { + Some(value) => (value, true), + None => (&NULL, false), + }, + _ => (&NULL, false), + }; + ( + Differ { + baseline, + forced: self.forced, + }, + existed, + ) + } + + /// Compare a freshly built scalar/array against the baseline, flagging emitted nulls as forced. + fn scalar(self, value: Value) -> Result { + if self.baseline == &value { + Ok(Node::Same) + } else { + // A genuine null can't be stored: merge patch would read it as a key deletion. + if value.is_null() { + self.forced.set(true); + } + Ok(Node::Diff(value)) } } } -fn diff_objects(old: &Map, new: &Map, patch: &mut Map, forced: &mut bool) { - // Keys present in old but missing from new become explicit null deletions. - for key in old.keys() { - if !new.contains_key(key) { - patch.insert(key.clone(), Value::Null); +/// Minimal serde error for the diffing serializer. JSON-shaped data never produces one in practice. +#[derive(Debug)] +struct Error(String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Error {} + +impl serde::ser::Error for Error { + fn custom(msg: M) -> Self { + Error(msg.to_string()) + } +} + +/// Build a Value with no diffing (used for array elements, which merge patch replaces wholesale). +fn to_plain(value: &T) -> Result { + serde_json::to_value(value).map_err(|e| Error(e.to_string())) +} + +impl<'a> Serializer for Differ<'a> { + type Ok = Node; + type Error = Error; + type SerializeSeq = SeqDiff<'a>; + type SerializeTuple = SeqDiff<'a>; + type SerializeTupleStruct = SeqDiff<'a>; + type SerializeTupleVariant = VariantSeq<'a>; + type SerializeMap = MapDiff<'a>; + type SerializeStruct = MapDiff<'a>; + type SerializeStructVariant = VariantMap<'a>; + + fn serialize_bool(self, v: bool) -> Result { + self.scalar(Value::Bool(v)) + } + fn serialize_i8(self, v: i8) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_i16(self, v: i16) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_i32(self, v: i32) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_i64(self, v: i64) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_i128(self, v: i128) -> Result { + self.scalar(to_plain(&v)?) + } + fn serialize_u8(self, v: u8) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_u16(self, v: u16) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_u32(self, v: u32) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_u64(self, v: u64) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_u128(self, v: u128) -> Result { + self.scalar(to_plain(&v)?) + } + fn serialize_f32(self, v: f32) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_f64(self, v: f64) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_char(self, v: char) -> Result { + self.scalar(Value::from(v.to_string())) + } + fn serialize_str(self, v: &str) -> Result { + // Strings are the common churn-free field, so compare against the baseline without allocating a + // `Value::String` on the unchanged path. + if matches!(self.baseline, Value::String(b) if b == v) { + Ok(Node::Same) + } else { + Ok(Node::Diff(Value::from(v))) } } + fn serialize_bytes(self, v: &[u8]) -> Result { + self.scalar(to_plain(v)?) + } + fn serialize_none(self) -> Result { + self.scalar(Value::Null) + } + fn serialize_some(self, value: &T) -> Result { + value.serialize(self) + } + fn serialize_unit(self) -> Result { + self.scalar(Value::Null) + } + fn serialize_unit_struct(self, _name: &'static str) -> Result { + self.scalar(Value::Null) + } + fn serialize_unit_variant(self, _name: &'static str, _idx: u32, variant: &'static str) -> Result { + self.scalar(Value::from(variant)) + } + fn serialize_newtype_struct(self, _name: &'static str, value: &T) -> Result { + value.serialize(self) + } + fn serialize_newtype_variant( + self, + _name: &'static str, + _idx: u32, + variant: &'static str, + value: &T, + ) -> Result { + // An externally-tagged newtype variant serializes as `{ "Variant": value }`. Diff that object + // against the baseline like any other object, so the tag is preserved and the payload diffs + // minimally (a variant switch deletes the old tag and adds the new one). + variant_object(variant, to_plain(value)?).serialize(self) + } + fn serialize_seq(self, len: Option) -> Result, Error> { + Ok(SeqDiff { + differ: self, + items: Vec::with_capacity(len.unwrap_or(0)), + }) + } + fn serialize_tuple(self, len: usize) -> Result, Error> { + self.serialize_seq(Some(len)) + } + fn serialize_tuple_struct(self, _name: &'static str, len: usize) -> Result, Error> { + self.serialize_seq(Some(len)) + } + fn serialize_tuple_variant( + self, + _name: &'static str, + _idx: u32, + variant: &'static str, + len: usize, + ) -> Result, Error> { + // A tuple variant serializes as `{ "Variant": [..] }`, replaced wholesale. + Ok(VariantSeq { + differ: self, + variant, + items: Vec::with_capacity(len), + }) + } + fn serialize_map(self, _len: Option) -> Result, Error> { + Ok(MapDiff { + differ: self, + patch: Map::new(), + seen: Vec::new(), + added_key: false, + pending_key: None, + }) + } + fn serialize_struct(self, _name: &'static str, len: usize) -> Result, Error> { + self.serialize_map(Some(len)) + } + fn serialize_struct_variant( + self, + _name: &'static str, + _idx: u32, + variant: &'static str, + _len: usize, + ) -> Result, Error> { + // A struct variant serializes as `{ "Variant": { .. } }`, replaced wholesale. + Ok(VariantMap { + differ: self, + variant, + fields: Map::new(), + }) + } +} + +/// Wrap a value as an externally-tagged variant object `{ variant: value }`. +fn variant_object(variant: &str, value: Value) -> Value { + let mut object = Map::new(); + object.insert(variant.to_owned(), value); + Value::Object(object) +} + +/// Collects a tuple variant's fields into `{ variant: [..] }`, then diffs it against the baseline. +struct VariantSeq<'a> { + differ: Differ<'a>, + variant: &'static str, + items: Vec, +} + +impl serde::ser::SerializeTupleVariant for VariantSeq<'_> { + type Ok = Node; + type Error = Error; + fn serialize_field(&mut self, value: &T) -> Result<(), Error> { + self.items.push(to_plain(value)?); + Ok(()) + } + fn end(self) -> Result { + variant_object(self.variant, Value::Array(self.items)).serialize(self.differ) + } +} + +/// Collects a struct variant's fields into `{ variant: { .. } }`, then diffs it against the baseline. +struct VariantMap<'a> { + differ: Differ<'a>, + variant: &'static str, + fields: Map, +} - for (key, new_val) in new { - let old_val = old.get(key); - if old_val == Some(new_val) { - continue; +impl serde::ser::SerializeStructVariant for VariantMap<'_> { + type Ok = Node; + type Error = Error; + fn serialize_field(&mut self, key: &'static str, value: &T) -> Result<(), Error> { + self.fields.insert(key.to_owned(), to_plain(value)?); + Ok(()) + } + fn end(self) -> Result { + variant_object(self.variant, Value::Object(self.fields)).serialize(self.differ) + } +} + +/// Arrays are replaced wholesale by merge patch, so this builds the full new array and compares it +/// to the baseline in one shot. +struct SeqDiff<'a> { + differ: Differ<'a>, + items: Vec, +} + +impl SerializeSeq for SeqDiff<'_> { + type Ok = Node; + type Error = Error; + fn serialize_element(&mut self, value: &T) -> Result<(), Error> { + self.items.push(to_plain(value)?); + Ok(()) + } + fn end(self) -> Result { + self.differ.scalar(Value::Array(self.items)) + } +} + +impl serde::ser::SerializeTuple for SeqDiff<'_> { + type Ok = Node; + type Error = Error; + fn serialize_element(&mut self, value: &T) -> Result<(), Error> { + SerializeSeq::serialize_element(self, value) + } + fn end(self) -> Result { + SerializeSeq::end(self) + } +} + +impl serde::ser::SerializeTupleStruct for SeqDiff<'_> { + type Ok = Node; + type Error = Error; + fn serialize_field(&mut self, value: &T) -> Result<(), Error> { + SerializeSeq::serialize_element(self, value) + } + fn end(self) -> Result { + SerializeSeq::end(self) + } +} + +/// Objects recurse: each entry diffs against the baseline's child, and only changed entries land in +/// the patch. Keys present in the baseline but absent now become explicit null deletions. +struct MapDiff<'a> { + differ: Differ<'a>, + patch: Map, + seen: Vec, + // Set when a field's key was absent from the baseline. Lets `finish` skip the deletion scan when + // the new keys are exactly the baseline keys (the common, churn-free case). + added_key: bool, + pending_key: Option, +} + +impl MapDiff<'_> { + fn entry(&mut self, key: String, existed: bool, node: Node) { + self.added_key |= !existed; + if let Node::Diff(value) = node { + self.patch.insert(key.clone(), value); } + self.seen.push(key); + } - // Recurse into nested objects so unchanged sibling keys stay out of the patch. - if let (Some(Value::Object(old_obj)), Value::Object(new_obj)) = (old_val, new_val) { - let mut sub = Map::new(); - diff_objects(old_obj, new_obj, &mut sub, forced); - if !sub.is_empty() { - patch.insert(key.clone(), Value::Object(sub)); + fn finish(self) -> Result { + let mut patch = self.patch; + if let Value::Object(base) = self.differ.baseline { + // A deletion is only possible if some key was added or the counts differ. Otherwise the new + // keys are exactly the baseline keys, so there's nothing to delete and we skip the scan, + // keeping the common path O(1) rather than O(n^2). A removed key is a clean delete (explicit + // null), and unlike a value set to null it does not force a snapshot. + if self.added_key || self.seen.len() != base.len() { + let seen: HashSet<&str> = self.seen.iter().map(String::as_str).collect(); + for key in base.keys() { + if !seen.contains(key.as_str()) { + patch.insert(key.clone(), Value::Null); + } + } } - continue; } - - // Added or replaced with a non-object value. A literal null can't be stored: merge patch - // would delete the key. Arrays are kept in the patch and replace the target wholesale. - if new_val.is_null() { - *forced = true; + if patch.is_empty() { + Ok(Node::Same) + } else { + Ok(Node::Diff(Value::Object(patch))) } - patch.insert(key.clone(), new_val.clone()); + } +} + +impl SerializeMap for MapDiff<'_> { + type Ok = Node; + type Error = Error; + fn serialize_key(&mut self, key: &T) -> Result<(), Error> { + // Extract the key string in a single allocation (no intermediate Value). + self.pending_key = Some(key.serialize(KeySer)?); + Ok(()) + } + fn serialize_value(&mut self, value: &T) -> Result<(), Error> { + let key = self.pending_key.take().expect("serialize_key precedes serialize_value"); + let (child, existed) = self.differ.child(&key); + let node = value.serialize(child)?; + self.entry(key, existed, node); + Ok(()) + } + fn end(self) -> Result { + self.finish() + } +} + +impl SerializeStruct for MapDiff<'_> { + type Ok = Node; + type Error = Error; + fn serialize_field(&mut self, key: &'static str, value: &T) -> Result<(), Error> { + let (child, existed) = self.differ.child(key); + let node = value.serialize(child)?; + self.entry(key.to_owned(), existed, node); + Ok(()) + } + // A field skipped via `skip_serializing_if` is simply never offered here, so it stays out of `seen` + // and `finish` emits it as a null deletion if the baseline had it (the default `skip_field` suffices). + fn end(self) -> Result { + self.finish() + } +} + +/// Serializes a map key to its `String`, the only form JSON object keys take. Anything else is an +/// error, mirroring `serde_json`'s own key handling. +struct KeySer; + +impl Serializer for KeySer { + type Ok = String; + type Error = Error; + type SerializeSeq = Impossible; + type SerializeTuple = Impossible; + type SerializeTupleStruct = Impossible; + type SerializeTupleVariant = Impossible; + type SerializeMap = Impossible; + type SerializeStruct = Impossible; + type SerializeStructVariant = Impossible; + + fn serialize_str(self, v: &str) -> Result { + Ok(v.to_owned()) + } + fn serialize_char(self, v: char) -> Result { + Ok(v.to_string()) + } + fn serialize_bool(self, v: bool) -> Result { + Ok(v.to_string()) + } + fn serialize_i8(self, v: i8) -> Result { + Ok(v.to_string()) + } + fn serialize_i16(self, v: i16) -> Result { + Ok(v.to_string()) + } + fn serialize_i32(self, v: i32) -> Result { + Ok(v.to_string()) + } + fn serialize_i64(self, v: i64) -> Result { + Ok(v.to_string()) + } + fn serialize_u8(self, v: u8) -> Result { + Ok(v.to_string()) + } + fn serialize_u16(self, v: u16) -> Result { + Ok(v.to_string()) + } + fn serialize_u32(self, v: u32) -> Result { + Ok(v.to_string()) + } + fn serialize_u64(self, v: u64) -> Result { + Ok(v.to_string()) + } + fn serialize_unit_variant(self, _name: &'static str, _idx: u32, variant: &'static str) -> Result { + Ok(variant.to_owned()) + } + fn serialize_newtype_struct(self, _name: &'static str, value: &T) -> Result { + value.serialize(self) + } + fn serialize_some(self, value: &T) -> Result { + value.serialize(self) + } + fn serialize_f32(self, _v: f32) -> Result { + Err(Error("float map key".into())) + } + fn serialize_f64(self, _v: f64) -> Result { + Err(Error("float map key".into())) + } + fn serialize_bytes(self, _v: &[u8]) -> Result { + Err(Error("bytes map key".into())) + } + fn serialize_none(self) -> Result { + Err(Error("null map key".into())) + } + fn serialize_unit(self) -> Result { + Err(Error("unit map key".into())) + } + fn serialize_unit_struct(self, _name: &'static str) -> Result { + Err(Error("unit struct map key".into())) + } + fn serialize_newtype_variant( + self, + _name: &'static str, + _idx: u32, + _variant: &'static str, + _value: &T, + ) -> Result { + Err(Error("newtype variant map key".into())) + } + fn serialize_seq(self, _len: Option) -> Result { + Err(Error("seq map key".into())) + } + fn serialize_tuple(self, _len: usize) -> Result { + Err(Error("tuple map key".into())) + } + fn serialize_tuple_struct(self, _name: &'static str, _len: usize) -> Result { + Err(Error("tuple struct map key".into())) + } + fn serialize_tuple_variant( + self, + _name: &'static str, + _idx: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(Error("tuple variant map key".into())) + } + fn serialize_map(self, _len: Option) -> Result { + Err(Error("map map key".into())) + } + fn serialize_struct(self, _name: &'static str, _len: usize) -> Result { + Err(Error("struct map key".into())) + } + fn serialize_struct_variant( + self, + _name: &'static str, + _idx: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(Error("struct variant map key".into())) } } @@ -70,18 +575,76 @@ mod test { use super::*; use serde_json::json; - /// Applying the patch to old should reproduce new (RFC 7396 semantics). - fn assert_roundtrip(old: Value, new: Value) { - let result = diff(&old, &new); - assert!(!result.forced_snapshot, "expected a delta, got a forced snapshot"); - let mut applied = old; - json_patch::merge(&mut applied, &result.patch); - assert_eq!(applied, new); + /// A straightforward Value-vs-Value merge-patch diff, used only as a test oracle: the production + /// `diff` (the serializer) must agree with it on every case. + fn reference(old: &Value, new: &Value) -> Diff { + fn objects( + old: &Map, + new: &Map, + patch: &mut Map, + forced: &mut bool, + ) { + for key in old.keys() { + if !new.contains_key(key) { + patch.insert(key.clone(), Value::Null); + } + } + for (key, new_val) in new { + let old_val = old.get(key); + if old_val == Some(new_val) { + continue; + } + if let (Some(Value::Object(old_obj)), Value::Object(new_obj)) = (old_val, new_val) { + let mut sub = Map::new(); + objects(old_obj, new_obj, &mut sub, forced); + if !sub.is_empty() { + patch.insert(key.clone(), Value::Object(sub)); + } + continue; + } + if new_val.is_null() { + *forced = true; + } + patch.insert(key.clone(), new_val.clone()); + } + } + + if let (Value::Object(old_obj), Value::Object(new_obj)) = (old, new) { + let mut patch = Map::new(); + let mut forced = false; + objects(old_obj, new_obj, &mut patch, &mut forced); + Diff { + patch: Value::Object(patch), + forced_snapshot: forced, + } + } else { + Diff { + patch: new.clone(), + forced_snapshot: true, + } + } + } + + /// The serializer must produce the same patch and forced flag as the reference oracle, and (when + /// not forced) applying the patch to `old` must reproduce `new`. + fn check(old: Value, new: Value) { + let want = reference(&old, &new); + let got = diff(&old, &new); + assert_eq!(got.patch, want.patch, "patch mismatch for {old} -> {new}"); + assert_eq!( + got.forced_snapshot, want.forced_snapshot, + "forced mismatch for {old} -> {new}" + ); + if !got.forced_snapshot { + let mut applied = old.clone(); + json_patch::merge(&mut applied, &got.patch); + assert_eq!(applied, new, "patch did not roundtrip for {old} -> {new}"); + } } #[test] fn changed_scalar() { - assert_roundtrip(json!({ "a": 1, "b": 2 }), json!({ "a": 1, "b": 3 })); + check(json!({ "a": 1, "b": 2 }), json!({ "a": 1, "b": 3 })); } #[test] @@ -89,6 +652,7 @@ mod test { let result = diff(&json!({ "a": 1 }), &json!({ "a": 1, "b": 2 })); assert!(!result.forced_snapshot); assert_eq!(result.patch, json!({ "b": 2 })); + check(json!({ "a": 1 }), json!({ "a": 1, "b": 2 })); } #[test] @@ -96,7 +660,7 @@ mod test { let result = diff(&json!({ "a": 1, "b": 2 }), &json!({ "a": 1 })); assert!(!result.forced_snapshot, "removing a key is a clean delete"); assert_eq!(result.patch, json!({ "b": null })); - assert_roundtrip(json!({ "a": 1, "b": 2 }), json!({ "a": 1 })); + check(json!({ "a": 1, "b": 2 }), json!({ "a": 1 })); } #[test] @@ -104,6 +668,14 @@ mod test { let result = diff(&json!({ "o": { "x": 1, "y": 2 } }), &json!({ "o": { "x": 1, "y": 9 } })); assert!(!result.forced_snapshot); assert_eq!(result.patch, json!({ "o": { "y": 9 } })); + check(json!({ "o": { "x": 1, "y": 2 } }), json!({ "o": { "x": 1, "y": 9 } })); + } + + #[test] + fn unchanged_object_is_empty_patch() { + let result = diff(&json!({ "a": 1, "o": { "x": 1 } }), &json!({ "a": 1, "o": { "x": 1 } })); + assert!(!result.forced_snapshot); + assert_eq!(result.patch, json!({})); } #[test] @@ -111,22 +683,35 @@ mod test { let result = diff(&json!({ "a": [1, 2] }), &json!({ "a": [1, 2, 3] })); assert!(!result.forced_snapshot); assert_eq!(result.patch, json!({ "a": [1, 2, 3] })); - assert_roundtrip(json!({ "a": [1, 2] }), json!({ "a": [1, 2, 3] })); + check(json!({ "a": [1, 2] }), json!({ "a": [1, 2, 3] })); + } + + #[test] + fn unchanged_array_is_pruned() { + let result = diff(&json!({ "a": [1, 2, 3], "b": 1 }), &json!({ "a": [1, 2, 3], "b": 2 })); + assert_eq!( + result.patch, + json!({ "b": 2 }), + "an unchanged array stays out of the patch" + ); } #[test] fn added_array_is_delta() { - let result = diff(&json!({ "a": 1 }), &json!({ "a": 1, "b": [1] })); - assert!(!result.forced_snapshot); - assert_eq!(result.patch, json!({ "b": [1] })); + check(json!({ "a": 1 }), json!({ "a": 1, "b": [1] })); } #[test] fn nested_array_is_delta() { - let result = diff(&json!({ "o": { "x": 1 } }), &json!({ "o": { "x": 1, "list": [1] } })); - assert!(!result.forced_snapshot); - assert_eq!(result.patch, json!({ "o": { "list": [1] } })); - assert_roundtrip(json!({ "o": { "x": 1 } }), json!({ "o": { "x": 1, "list": [1] } })); + check(json!({ "o": { "x": 1 } }), json!({ "o": { "x": 1, "list": [1] } })); + } + + #[test] + fn array_of_objects_replaces_wholesale() { + check( + json!({ "items": [{ "id": 1, "v": 1 }, { "id": 2, "v": 2 }] }), + json!({ "items": [{ "id": 1, "v": 9 }, { "id": 2, "v": 2 }] }), + ); } #[test] @@ -134,17 +719,324 @@ mod test { // A genuine null value can't be represented: merge patch would delete the key. let result = diff(&json!({ "a": 1 }), &json!({ "a": null })); assert!(result.forced_snapshot); + assert!(reference(&json!({ "a": 1 }), &json!({ "a": null })).forced_snapshot); + } + + #[test] + fn nested_null_forces_snapshot() { + let old = json!({ "o": { "x": 1 } }); + let new = json!({ "o": { "x": null } }); + assert!(diff(&old, &new).forced_snapshot); + assert_eq!(diff(&old, &new).forced_snapshot, reference(&old, &new).forced_snapshot); } #[test] fn replacing_object_with_scalar() { - assert_roundtrip(json!({ "a": { "x": 1 } }), json!({ "a": 5 })); + check(json!({ "a": { "x": 1 } }), json!({ "a": 5 })); + } + + #[test] + fn replacing_scalar_with_object() { + check(json!({ "a": 5 }), json!({ "a": { "x": 1 } })); } #[test] fn non_object_root_forces_snapshot() { let result = diff(&json!(1), &json!(2)); assert!(result.forced_snapshot); + assert_eq!(result.patch, json!(2)); + } + + #[test] + fn array_root_forces_snapshot() { + let result = diff(&json!([1, 2]), &json!([1, 2, 3])); + assert!(result.forced_snapshot); + assert_eq!(result.patch, json!([1, 2, 3])); + } + + #[test] + fn unchanged_scalar_root_is_not_forced() { + // An equal non-object root is a no-op (empty patch), matching the producer's dedup. + let result = diff(&json!(7), &json!(7)); + assert!(!result.forced_snapshot); + assert_eq!(result.patch, json!({})); + } + + #[test] + fn floats_and_bools_and_strings() { + check( + json!({ "f": 1.5, "b": true, "s": "hi" }), + json!({ "f": 2.5, "b": false, "s": "bye" }), + ); + } + + // ---- Typed structs (the serializer's whole point: diff `T` without building its Value) ---- + + #[derive(serde::Serialize, serde::Deserialize, Default, PartialEq, Debug)] + struct Doc { + #[serde(skip_serializing_if = "Option::is_none")] + video: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scte35: Option, + count: u64, + tags: Vec, + } + + /// Diff a typed struct against the Value of a prior typed struct; the patch must roundtrip. + fn check_struct(old: &Doc, new: &Doc) { + let old_value = serde_json::to_value(old).unwrap(); + let result = diff(&old_value, new); + + // Cross-check against the oracle fed the equivalent Values. + let new_value = serde_json::to_value(new).unwrap(); + let want = reference(&old_value, &new_value); + assert_eq!(result.patch, want.patch, "struct patch differs from oracle"); + assert_eq!(result.forced_snapshot, want.forced_snapshot); + + if !result.forced_snapshot { + let mut applied = old_value; + json_patch::merge(&mut applied, &result.patch); + assert_eq!(applied, new_value, "struct patch did not roundtrip"); + } + } + + #[test] + fn struct_field_change() { + check_struct( + &Doc { + count: 1, + tags: vec!["a".into()], + ..Default::default() + }, + &Doc { + count: 2, + tags: vec!["a".into()], + ..Default::default() + }, + ); + } + + #[test] + fn struct_option_some_to_none_is_deletion() { + // A skipped field (None) must become a null deletion of the previously-present key. + let result = diff( + &serde_json::to_value(Doc { + video: Some("v1".into()), + count: 1, + ..Default::default() + }) + .unwrap(), + &Doc { + video: None, + count: 1, + ..Default::default() + }, + ); + assert!(!result.forced_snapshot, "deleting a skipped key is clean"); + assert_eq!(result.patch, json!({ "video": null })); + check_struct( + &Doc { + video: Some("v1".into()), + count: 1, + ..Default::default() + }, + &Doc { + video: None, + count: 1, + ..Default::default() + }, + ); + } + + #[test] + fn struct_option_none_to_some_is_addition() { + check_struct( + &Doc { + count: 1, + ..Default::default() + }, + &Doc { + scte35: Some(42), + count: 1, + ..Default::default() + }, + ); + } + + #[test] + fn struct_unchanged_is_empty_patch() { + let doc = Doc { + video: Some("v".into()), + scte35: Some(1), + count: 7, + tags: vec!["x".into(), "y".into()], + }; + let result = diff(&serde_json::to_value(&doc).unwrap(), &doc); + assert!(!result.forced_snapshot); + assert_eq!(result.patch, json!({})); + } + + #[test] + fn struct_vec_changes_wholesale() { + check_struct( + &Doc { + count: 1, + tags: vec!["a".into(), "b".into()], + ..Default::default() + }, + &Doc { + count: 1, + tags: vec!["a".into(), "c".into()], + ..Default::default() + }, + ); + } + + #[derive(serde::Serialize)] + struct Nested { + inner: Inner, + name: String, + } + #[derive(serde::Serialize)] + struct Inner { + a: u32, + b: u32, + } + + #[test] + fn nested_struct_only_changed_field() { + let old = serde_json::to_value(Nested { + inner: Inner { a: 1, b: 2 }, + name: "n".into(), + }) + .unwrap(); + let new = Nested { + inner: Inner { a: 1, b: 9 }, + name: "n".into(), + }; + let result = diff(&old, &new); + assert_eq!(result.patch, json!({ "inner": { "b": 9 } })); + assert!(!result.forced_snapshot); + } + + #[derive(serde::Serialize)] + enum Tag { + Active, + Idle, + } + + #[derive(serde::Serialize)] + struct Stated { + state: Tag, + seq: u32, + } + + #[test] + fn unit_enum_variant_is_string() { + // Externally-tagged unit variants serialize as the variant name string. + let old = serde_json::to_value(Stated { + state: Tag::Active, + seq: 1, + }) + .unwrap(); + assert_eq!(old, json!({ "state": "Active", "seq": 1 })); + let result = diff( + &old, + &Stated { + state: Tag::Idle, + seq: 1, + }, + ); + assert!(!result.forced_snapshot); + assert_eq!(result.patch, json!({ "state": "Idle" })); + } + + /// Diff a typed value against the Value of a prior value: the patch must match the oracle fed the + /// equivalent Values, and roundtrip. + fn check_typed(old: &Value, new: &T) { + let new_value = serde_json::to_value(new).unwrap(); + let want = reference(old, &new_value); + let got = diff(old, new); + assert_eq!(got.patch, want.patch, "patch differs from oracle"); + assert_eq!(got.forced_snapshot, want.forced_snapshot, "forced differs from oracle"); + if !got.forced_snapshot { + let mut applied = old.clone(); + json_patch::merge(&mut applied, &got.patch); + assert_eq!(applied, new_value, "patch did not roundtrip"); + } + } + + #[derive(serde::Serialize)] + enum Payload { + Newtype(u32), + Tuple(u32, String), + Struct { x: u32, y: u32 }, + } + + #[derive(serde::Serialize)] + struct Holder { + payload: Payload, + seq: u32, + } + + #[test] + fn newtype_variant_keeps_its_tag() { + // Regression: a newtype variant must serialize as `{ "Newtype": v }`, not collapse to `v`. + let old = serde_json::to_value(Holder { + payload: Payload::Newtype(1), + seq: 0, + }) + .unwrap(); + assert_eq!(old, json!({ "payload": { "Newtype": 1 }, "seq": 0 })); + let result = diff( + &old, + &Holder { + payload: Payload::Newtype(2), + seq: 0, + }, + ); + assert_eq!(result.patch, json!({ "payload": { "Newtype": 2 } })); + check_typed( + &old, + &Holder { + payload: Payload::Newtype(2), + seq: 0, + }, + ); + } + + #[test] + fn tuple_variant_keeps_its_tag() { + let old = serde_json::to_value(Holder { + payload: Payload::Tuple(1, "a".into()), + seq: 0, + }) + .unwrap(); + assert_eq!(old, json!({ "payload": { "Tuple": [1, "a"] }, "seq": 0 })); + check_typed( + &old, + &Holder { + payload: Payload::Tuple(2, "a".into()), + seq: 0, + }, + ); + } + + #[test] + fn struct_variant_keeps_its_tag() { + let old = serde_json::to_value(Holder { + payload: Payload::Struct { x: 1, y: 2 }, + seq: 0, + }) + .unwrap(); + assert_eq!(old, json!({ "payload": { "Struct": { "x": 1, "y": 2 } }, "seq": 0 })); + check_typed( + &old, + &Holder { + payload: Payload::Struct { x: 1, y: 9 }, + seq: 0, + }, + ); } #[derive(serde::Deserialize)] @@ -173,4 +1065,24 @@ mod test { } } } + + /// Exercise the diff over a sequence of evolving documents, asserting agreement with the oracle + /// and full roundtrip at every step (the way the producer applies deltas). + #[test] + fn evolving_document_matches_oracle() { + let mut docs = Vec::new(); + for tick in 0u64..40 { + docs.push(json!({ + "id": "device-1", + "static": { "model": "x", "tags": ["a", "b", "c"] }, + "counters": { "n": tick, "errors": tick / 10 }, + "reading": (tick as f64 * 0.5), + "flags": { "online": tick % 2 == 0, "charging": tick % 3 == 0 }, + "list": [tick, tick + 1], + })); + } + for pair in docs.windows(2) { + check(pair[0].clone(), pair[1].clone()); + } + } } diff --git a/rs/moq-json/src/lib.rs b/rs/moq-json/src/lib.rs index 352c16a90..d5e9cbaad 100644 --- a/rs/moq-json/src/lib.rs +++ b/rs/moq-json/src/lib.rs @@ -9,7 +9,6 @@ //! Deltas are controlled by [`ProducerConfig::delta_ratio`]. A ratio of `0` disables them, so every //! change is a fresh snapshot group, matching a plain "one JSON blob per group" track. -mod compression; mod diff; use std::marker::PhantomData; @@ -18,12 +17,12 @@ use std::sync::{Arc, Mutex, MutexGuard}; use std::task::Poll; use bytes::Bytes; +use moq_flate::{Decoder, Encoder}; use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Value; -use crate::compression::{Decoder, Encoder}; -use crate::diff::diff; +pub use crate::diff::{Diff, diff}; /// Maximum frames (snapshot + deltas) in a single group before a new snapshot is forced. /// @@ -45,13 +44,9 @@ pub enum Error { #[error("json: {0}")] Json(String), - /// A compressed frame could not be decoded (malformed or truncated stream). - #[error("decompression failed")] - Decompress, - - /// A frame's decompressed size exceeded the limit (zip-bomb guard). - #[error("decompressed frame exceeded {0} bytes")] - TooLarge(u64), + /// A compressed frame could not be decoded (malformed, truncated, or oversized). + #[error(transparent)] + Flate(#[from] moq_flate::Error), } impl From for Error { @@ -74,10 +69,11 @@ pub struct ProducerConfig { /// /// A ratio of `0` disables deltas: every change is published as a new snapshot group. /// - /// A positive ratio enables deltas. A delta is appended to the current group as long as the - /// accumulated deltas (excluding the snapshot frame) stay within `ratio` times the size of a - /// snapshot; otherwise a new snapshot group is started. So `1` allows deltas totalling up - /// to one snapshot before rolling, and a larger ratio tolerates more deltas per snapshot. + /// A positive ratio enables deltas. A new snapshot group is started once the deltas *already + /// written* to the current group (excluding the snapshot frame) exceed `ratio` times the snapshot + /// size. The pending delta is excluded from that check, so the one that first crosses the budget + /// still lands before the group rolls. So `1` allows roughly one snapshot's worth of deltas before + /// rolling, and a larger ratio tolerates more. /// /// When [`compression`](Self::compression) is on, both sides of the comparison are measured on /// the *compressed* frame sizes (the real wire cost). @@ -161,11 +157,7 @@ impl Producer { /// /// Does nothing if the value is unchanged from the previous publish. pub fn update(&mut self, value: &T) -> Result<()> { - let json = serde_json::to_value(value)?; - // Serialize the value directly (not via `json`) so a snapshot preserves the type's own - // field order, keeping the wire bytes identical to serializing `T` straight to a frame. - let snapshot = serde_json::to_vec(value)?; - self.inner.lock().unwrap().update(json, snapshot) + self.inner.lock().unwrap().update(value) } /// Lock the current value for in-place editing, publishing on drop. @@ -233,15 +225,8 @@ impl Drop for Guard<'_, T> { return; } - let Ok(json) = serde_json::to_value(&self.value) else { - return; - }; - let Ok(snapshot) = serde_json::to_vec(&self.value) else { - return; - }; - // We already hold the lock, so publish through the held guard rather than re-locking. - let _ = self.inner.update(json, snapshot); + let _ = self.inner.update(&self.value); } } @@ -263,75 +248,67 @@ struct Inner { } impl Inner { - fn update(&mut self, json: Value, snapshot: Vec) -> Result<()> { - if self.last.as_ref() == Some(&json) { + fn update(&mut self, value: &T) -> Result<()> { + // The first publish (or the first after `finish`) has no baseline to diff against, so it seeds + // the stream with a snapshot. + let Some(last) = self.last.as_ref() else { + return self.snapshot(value); + }; + + // Diff straight off `T`, without building a full `Value` for the new value first. + let Diff { patch, forced_snapshot } = diff(last, value); + + // An empty object patch with no forced null means the value is unchanged: publish nothing. + if !forced_snapshot && patch.as_object().is_some_and(serde_json::Map::is_empty) { return Ok(()); } - match self.delta(&json, snapshot.len())? { - Some(slice) => { - let group = self.group.as_mut().expect("delta requires an open group"); - let len = slice.len() as u64; - group.write_frame_now(slice)?; - self.delta_bytes += len; - self.group_frames += 1; - } - None => self.snapshot(snapshot)?, + // A forced snapshot (a genuine null, or a non-object root) or an exhausted delta budget rolls a + // new group; otherwise the change rides as a delta in the open group. + if forced_snapshot || !self.delta_allowed() { + return self.snapshot(value); } - self.last = Some(json); + // Compress into the per-group window only now, for a frame we are committed to writing. + let bytes = serde_json::to_vec(&patch)?; + let slice = match self.encoder.as_mut() { + Some(encoder) => encoder.frame(&bytes), + None => Bytes::from(bytes), + }; + let len = slice.len() as u64; + self.group + .as_mut() + .expect("delta_allowed guarantees an open group") + .write_frame_now(slice)?; + self.delta_bytes += len; + self.group_frames += 1; + + // Fold the delta into the baseline so the next diff is against the value we just published. + json_patch::merge(self.last.as_mut().expect("a snapshot precedes any delta"), &patch); Ok(()) } - /// Serialize (and, when compressing, compress) a delta if deltas are enabled and appending one - /// keeps the group within budget; otherwise `None`, signalling that a fresh snapshot should be - /// published instead. Returns the frame slice ready to write. - fn delta(&mut self, value: &Value, snapshot_len: usize) -> Result> { - let ratio = self.config.delta_ratio; - if ratio == 0 { - return Ok(None); - } - if self.group.is_none() || self.group_frames >= MAX_DELTA_FRAMES { - return Ok(None); - } - - let patch = { - let Some(last) = &self.last else { - return Ok(None); - }; - let diff = diff(last, value); - if diff.forced_snapshot { - return Ok(None); - } - serde_json::to_vec(&diff.patch)? - }; - - match self.encoder.as_mut() { - // Compressed: measure the delta's *compressed* slice size (the real wire cost) against the - // group's anchoring snapshot, also compressed. Encoding advances the per-group window; if - // the delta doesn't fit we roll a new group with a fresh encoder, discarding this slice - // (the abandoned window has no effect on the new group). - Some(encoder) => { - let slice = encoder.frame(&patch); - let projected = self.delta_bytes + slice.len() as u64; - if projected > ratio as u64 * self.snapshot_len { - return Ok(None); - } - Ok(Some(slice)) - } - // Uncompressed: raw delta bytes against a fresh snapshot of the current value. - None => { - let projected = self.delta_bytes + patch.len() as u64; - if projected > ratio as u64 * snapshot_len as u64 { - return Ok(None); - } - Ok(Some(Bytes::from(patch))) - } - } - } + /// Whether the current change may ride as a delta in the open group. + /// + /// The budget gate measures the deltas *already written* (excluding the frame about to land) + /// against the group's snapshot frame. Both are compressed sizes when compressing and raw + /// otherwise, so the comparison is like-for-like. Because the pending frame is excluded, the delta + /// that tips the group past `ratio * snapshot` still lands: a group overshoots by at most one delta + /// before rolling. + fn delta_allowed(&self) -> bool { + let ratio = self.config.delta_ratio as u64; + ratio != 0 + && self.group.is_some() + && self.group_frames < MAX_DELTA_FRAMES + && self.delta_bytes <= ratio * self.snapshot_len + } + + /// Start a new group with a full snapshot of `value` as its first frame, and reseed the baseline. + fn snapshot(&mut self, value: &T) -> Result<()> { + // Serialize directly from `value` so the snapshot frame preserves the type's own field order, + // keeping the wire bytes identical to serializing `T` straight to a frame. + let snapshot = serde_json::to_vec(value)?; - /// Start a new group with a full snapshot as its first frame. - fn snapshot(&mut self, snapshot: Vec) -> Result<()> { // The previous group is complete; no more frames will be appended to it. if let Some(mut group) = self.group.take() { group.finish()?; @@ -363,6 +340,8 @@ impl Inner { group.finish()?; } + // Reseed the baseline with the full new value for the next diff. + self.last = Some(serde_json::to_value(value)?); Ok(()) } @@ -415,8 +394,13 @@ impl Consumer { /// Poll for the next reconstructed value, without blocking. /// - /// Jumps to the newest group, reads its snapshot, and applies deltas in order, yielding the - /// reconstructed value after each frame. Switching to a newer group discards the older one. + /// Jumps to the newest group, reads its snapshot, and applies deltas in order. All frames already + /// buffered in the group are applied in one poll but only the resulting *latest* value is yielded: + /// the intermediate reconstructions are stale, so a late joiner (or any consumer that has fallen + /// behind) catches up to the head in a single step instead of replaying every superseded state. + /// Frames must still be decoded in order (the DEFLATE window and merge patches are sequential); + /// only the per-frame deserialize and yield are skipped. Switching to a newer group discards the + /// older one. pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { // Drain to the newest group, resetting reconstruction state whenever we switch. let track_finished = loop { @@ -433,15 +417,42 @@ impl Consumer { } }; - if let Some(group) = &mut self.group { + // Apply every frame currently buffered in the group, tracking whether any moved us forward and + // whether the group is still open with nothing buffered yet (vs. exhausted). + // `poll_read_frame` returns an owned `Poll`, so the borrow of `self.group` ends before the + // match arms, leaving `apply` (and clearing the group) free to take `&mut self`. + let mut advanced = false; + let mut group_pending = false; + while let Some(group) = &mut self.group { match group.poll_read_frame(waiter)? { - Poll::Ready(Some(frame)) => return Poll::Ready(Ok(Some(self.apply(frame)?))), + Poll::Ready(Some(frame)) => { + self.apply(frame)?; + advanced = true; + } // The current group is exhausted; wait for a newer one. - Poll::Ready(None) => self.group = None, - Poll::Pending => return Poll::Pending, + Poll::Ready(None) => { + self.group = None; + break; + } + // The group is still open but has nothing buffered yet. + Poll::Pending => { + group_pending = true; + break; + } } } + if advanced { + // Deserialize once, from the head of the backlog we just drained. + return Poll::Ready(Ok(Some(self.reconstruct()?))); + } + + // An open group may still deliver frames even after the track finishes (it was appended before + // the finish), so wait on it rather than ending the stream. + if group_pending { + return Poll::Pending; + } + if track_finished { Poll::Ready(Ok(None)) } else { @@ -459,11 +470,12 @@ impl Consumer { } let decoder = self.decoder.get_or_insert_with(Decoder::new); - decoder.frame(&slice) + Ok(decoder.frame(&slice)?) } - /// Apply one frame: frame 0 of a group is a snapshot, the rest are merge patches. - fn apply(&mut self, frame: Bytes) -> Result { + /// Apply one frame to the in-progress value: frame 0 of a group is a snapshot, the rest are merge + /// patches. Updates internal state only; call [`reconstruct`](Self::reconstruct) to materialize `T`. + fn apply(&mut self, frame: Bytes) -> Result<()> { let frame = self.decode(frame)?; if self.frames_read == 0 { self.current = Some(serde_json::from_slice(&frame)?); @@ -473,7 +485,12 @@ impl Consumer { json_patch::merge(current, &patch); } self.frames_read += 1; + Ok(()) + } + /// Materialize the current reconstructed value into `T`. Call only after at least one frame has + /// been applied in the current group. + fn reconstruct(&self) -> Result { let current = self .current .as_ref() @@ -588,15 +605,16 @@ mod test { #[test] fn tight_ratio_rolls_snapshots() { - // A ratio of 1 admits deltas only up to the snapshot size: with equal 7-byte frames that is a - // single delta per group, so it rolls every other update. (Still distinct from 0, which would - // disable deltas entirely and never produce the group-0 delta below.) + // A ratio of 1 budgets deltas up to one snapshot (equal 7-byte frames => 7 bytes). The gate + // checks the deltas already written, so the delta that tips the group over budget still lands + // (a one-frame overshoot): group 0 takes two deltas (14 bytes) before the fourth update rolls + // group 1. (Still distinct from 0, which disables deltas entirely.) let config = cfg(1); let (mut producer, track) = producer(config); producer.update(&json!({ "a": 1 })).unwrap(); // snapshot, group 0 - producer.update(&json!({ "a": 2 })).unwrap(); // delta, group 0 - producer.update(&json!({ "a": 3 })).unwrap(); // exceeds budget, rolls group 1 - producer.update(&json!({ "a": 4 })).unwrap(); // delta, group 1 + producer.update(&json!({ "a": 2 })).unwrap(); // delta, group 0 (deltas = 7) + producer.update(&json!({ "a": 3 })).unwrap(); // delta, group 0 (deltas = 14, now over budget) + producer.update(&json!({ "a": 4 })).unwrap(); // budget already exceeded, rolls group 1 producer.finish().unwrap(); assert_eq!(track.latest(), Some(1)); @@ -604,20 +622,21 @@ mod test { #[test] fn deltas_stay_within_ratio_times_snapshot() { - // The budget covers only the deltas, not the snapshot frame, measured against the current - // snapshot size. Single-digit values keep every frame at a constant 7 bytes (`{"n":N}`), so a - // `ratio = 8` admits 8 deltas (8x the snapshot, the inclusive limit) on top of the snapshot - // before the 9th delta rolls. + // The budget covers only the deltas, not the snapshot frame, measured against the group's + // snapshot size. Single-digit values keep every frame at a constant 7 bytes (`{"n":N}`), so + // `ratio = 8` budgets 56 bytes of deltas. The gate checks the deltas already written, so the + // group keeps filling until the accumulated deltas first exceed 56 (nine deltas = 63 bytes) and + // the next update rolls (a one-frame overshoot past the 56-byte budget). let config = cfg(8); let (mut producer, track) = producer(config); - for n in 0..=9 { + for n in 0..=10 { producer.update(&json!({ "n": n })).unwrap(); } producer.finish().unwrap(); - // Group 0 carries the snapshot plus 8 deltas (9 frames); the 9th delta opens group 1. + // Group 0 carries the snapshot plus 9 deltas (10 frames); the 10th delta opens group 1. assert_eq!(track.latest(), Some(1)); - assert_eq!(drain(track).last().unwrap(), &json!({ "n": 9 })); + assert_eq!(drain(track).last().unwrap(), &json!({ "n": 10 })); } #[test] @@ -707,7 +726,8 @@ mod test { #[test] fn newer_group_supersedes_in_progress_reconstruction() { - // A tight ratio lets one delta fit, then forces the next update into a new snapshot group. + // A tight ratio fills group 0 with a couple of deltas, then forces a later update into a new + // snapshot group (the gate overshoots the budget by one delta before rolling). let config = cfg(1); let (mut producer, track) = producer(config); let observer = producer.consume(); @@ -720,8 +740,9 @@ mod test { other => panic!("expected first value, got {other:?}"), } - producer.update(&json!({ "a": 2 })).unwrap(); // delta in group 0 - producer.update(&json!({ "a": 3 })).unwrap(); // exceeds budget, rolls group 1 + producer.update(&json!({ "a": 2 })).unwrap(); // delta in group 0 (deltas = 7) + producer.update(&json!({ "a": 3 })).unwrap(); // delta in group 0 (deltas = 14, now over budget) + producer.update(&json!({ "a": 4 })).unwrap(); // budget already exceeded, rolls group 1 producer.finish().unwrap(); assert_eq!(observer.latest(), Some(1)); @@ -730,7 +751,74 @@ mod test { while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) { last = Some(value); } - assert_eq!(last.unwrap(), json!({ "a": 3 })); + assert_eq!(last.unwrap(), json!({ "a": 4 })); + } + + #[test] + fn open_group_pends_after_track_finish() { + // A group appended before the track finishes may still deliver frames, so the consumer must + // keep waiting on it rather than ending the stream. Regression for the backlog-collapse poll. + let mut track = moq_net::BroadcastInfo::new() + .produce() + .create_track("test", None) + .unwrap(); + let mut group = track.append_group().unwrap(); + let consumer_track = track.subscribe(None); + track.finish().unwrap(); + + let mut consumer = Consumer::::new(consumer_track, ConsumerConfig::default()); + let waiter = kio::Waiter::noop(); + + // Track is finished but the open group is empty: pending, not end-of-stream. + assert!(matches!(consumer.poll_next(&waiter), Poll::Pending)); + + group + .write_frame_now(Bytes::from(serde_json::to_vec(&json!({ "a": 1 })).unwrap())) + .unwrap(); + group.finish().unwrap(); + + match consumer.poll_next(&waiter) { + Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": 1 })), + other => panic!("expected the catalog value, got {other:?}"), + } + } + + #[test] + fn late_joiner_collapses_backlog_to_latest() { + // A whole group's worth of snapshot + deltas is buffered before the consumer reads. It should + // apply them all but yield only the latest value once, not replay every superseded state. + let (mut producer, track) = producer(cfg(100)); + for n in 0..=20 { + producer.update(&json!({ "n": n })).unwrap(); + } + producer.finish().unwrap(); + + // One group (ratio is generous), so a single poll drains the backlog into one yield. + assert_eq!(track.latest(), Some(0)); + let values = drain(track); + assert_eq!( + values, + vec![json!({ "n": 20 })], + "backlog should collapse to the latest value" + ); + } + + #[test] + fn compressed_late_joiner_collapses_backlog_to_latest() { + // Same collapse, exercising the lazy decoder replaying the group's slices to warm its window. + let (mut producer, track) = producer(cfg_deflate(100)); + for n in 0..=20 { + producer.update(&json!({ "n": n })).unwrap(); + } + producer.finish().unwrap(); + + assert_eq!(track.latest(), Some(0)); + let values = drain_with(deflate_consumer(track)); + assert_eq!( + values, + vec![json!({ "n": 20 })], + "compressed backlog should collapse to the latest" + ); } #[test] @@ -773,6 +861,26 @@ mod test { assert_eq!(values.last().unwrap(), &json!({ "a": 5, "b": 2 })); } + #[test] + fn compressed_deltas_roll_on_compressed_budget() { + // With compression the budget is measured on compressed frame sizes: `snapshot_len` and + // `delta_bytes` are the compressed slice lengths, not the raw JSON. A tight ratio over many + // distinct updates must therefore roll at least one group, and a late joiner must still rebuild + // the final value across the compressed group boundary (per-group decoder reset). Guards against + // the budget regressing to raw lengths. + let (mut producer, track) = producer(cfg_deflate(2)); + for n in 0..=40 { + producer.update(&json!({ "n": n })).unwrap(); + } + producer.finish().unwrap(); + + assert!( + track.latest().unwrap() > 0, + "a tight ratio should roll at least one compressed group" + ); + assert_eq!(drain_with(deflate_consumer(track)).last().unwrap(), &json!({ "n": 40 })); + } + #[test] fn compression_shrinks_wire_frames() { // A repetitive payload should serialize to fewer wire bytes compressed than plaintext. diff --git a/rs/moq-mux/README.md b/rs/moq-mux/README.md index 9c02d550e..87732191e 100644 --- a/rs/moq-mux/README.md +++ b/rs/moq-mux/README.md @@ -11,7 +11,7 @@ Media muxers and demuxers for [Media over QUIC](https://moq.dev). Takes containerized or raw-codec media in, produces a [hang](https://github.com/moq-dev/moq/tree/main/rs/hang) broadcast — or the other way around. -**Containers:** fMP4 / CMAF, MKV / WebM, HLS, LOC, hang Legacy. +**Containers:** fMP4 / CMAF, MKV / WebM, MPEG-TS, FLV, LOC, hang Legacy. **Codecs:** H.264, H.265, AV1, AAC, Opus. The crate splits along two axes: diff --git a/rs/moq-mux/src/catalog/filter.rs b/rs/moq-mux/src/catalog/filter.rs index d032397ed..d5ac6e956 100644 --- a/rs/moq-mux/src/catalog/filter.rs +++ b/rs/moq-mux/src/catalog/filter.rs @@ -131,11 +131,21 @@ impl Stream for Filter { Poll::Ready(Ok((emit, epoch))) => { self.last_epoch = epoch; self.fresh_input = false; + // End with upstream: if this is the final snapshot (inner already EOF'd), + // drop the retained input so a later filter change can't revive the stream + // after it has emitted its last value. + if inner_eof { + self.last_input = None; + } Poll::Ready(Ok(Some(emit))) } Poll::Ready(Err(_)) => Poll::Ready(Ok(None)), Poll::Pending => { - if inner_eof && self.last_input.is_none() { + // EOF is terminal: once `inner` is exhausted and there's nothing fresh to + // emit, finish and drop the retained input so a post-EOF setter can't make + // the closure emit again (a still-pending snapshot returns Ready above). + if inner_eof { + self.last_input = None; Poll::Ready(Ok(None)) } else { Poll::Pending @@ -203,6 +213,22 @@ mod test { } } + /// A still-live stream: yields its snapshot once, then parks (never EOFs). Models a + /// real upstream that stays open so post-snapshot retargeting is exercised without + /// tripping the end-with-upstream path. + struct Live(Option); + + impl Stream for Live { + type Ext = (); + + fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { + match self.0.take() { + Some(catalog) => Poll::Ready(Ok(Some(catalog))), + None => Poll::Pending, + } + } + } + fn h264(name: &str) -> (String, VideoConfig) { let mut config = VideoConfig::new(H264 { profile: 0x42, @@ -293,10 +319,29 @@ mod test { } #[test] - fn set_video_after_snapshot_reemits() { + fn ends_after_upstream_eof() { let snapshot = catalog_with(vec![h264("lo"), h264("hi")], vec![]); let mut f = Filter::new(Once(Some(snapshot))); + // First poll emits the filtered snapshot. + assert!(matches!(f.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(Some(_))))); + // Upstream is exhausted, so the stream ends rather than parking forever. + assert!(matches!(f.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); + + // EOF is terminal: a filter change after the end must not revive the stream. + f.set_video(FilterVideo { + name: Some("hi".into()), + ..Default::default() + }); + assert!(matches!(f.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); + } + + #[test] + fn set_video_after_snapshot_reemits() { + // A live (not-yet-EOF) upstream, so the retarget re-applies to the retained snapshot. + let snapshot = catalog_with(vec![h264("lo"), h264("hi")], vec![]); + let mut f = Filter::new(Live(Some(snapshot))); + let first = match f.poll_next(&kio::Waiter::noop()) { Poll::Ready(Ok(Some(c))) => c, other => panic!("got {other:?}"), diff --git a/rs/moq-mux/src/catalog/msf/consumer.rs b/rs/moq-mux/src/catalog/msf/consumer.rs index c1adec969..743ffda6b 100644 --- a/rs/moq-mux/src/catalog/msf/consumer.rs +++ b/rs/moq-mux/src/catalog/msf/consumer.rs @@ -288,11 +288,11 @@ fn derive_from_codec_config(track: &moq_msf::Track, codec: &AudioCodec, init: by let mut buf = init; match codec { AudioCodec::AAC(_) => { + // AudioSpecificConfig carries valid variable-length extensions (SBR/PS) after + // the core fields, so `parse` consumes the whole buffer; bytes past the core + // fields are legitimate config, not trailing junk. let cfg = crate::codec::aac::Config::parse(&mut buf).map_err(|_| Error::MalformedAac(track.name.clone()))?; - if buf.has_remaining() { - return Err(Error::AacTrailingBytes(track.name.clone()).into()); - } Ok(DerivedAudio { sample_rate: cfg.sample_rate, channel_count: cfg.channel_count, diff --git a/rs/moq-mux/src/catalog/msf/mod.rs b/rs/moq-mux/src/catalog/msf/mod.rs index ebe5ce0d2..065accf74 100644 --- a/rs/moq-mux/src/catalog/msf/mod.rs +++ b/rs/moq-mux/src/catalog/msf/mod.rs @@ -50,9 +50,6 @@ pub enum Error { #[error("MSF audio track {0:?} has malformed OpusHead")] MalformedOpus(String), - #[error("MSF audio track {0:?} AudioSpecificConfig has trailing bytes")] - AacTrailingBytes(String), - #[error("MSF audio track {0:?} OpusHead has trailing bytes")] OpusTrailingBytes(String), diff --git a/rs/moq-mux/src/catalog/target.rs b/rs/moq-mux/src/catalog/target.rs index 61950e84e..5356660b4 100644 --- a/rs/moq-mux/src/catalog/target.rs +++ b/rs/moq-mux/src/catalog/target.rs @@ -143,6 +143,12 @@ impl Stream for Target { Poll::Ready(Ok((emit, epoch))) => { self.last_epoch = epoch; self.fresh_input = false; + // End with upstream: if this is the final snapshot (inner already EOF'd), + // drop the retained input so a later retarget can't revive the stream after + // it has emitted its last value. + if inner_eof { + self.last_input = None; + } Poll::Ready(Ok(Some(emit))) } Poll::Ready(Err(_)) => { @@ -150,7 +156,11 @@ impl Stream for Target { Poll::Ready(Ok(None)) } Poll::Pending => { - if inner_eof && self.last_input.is_none() { + // EOF is terminal: once `inner` is exhausted and there's nothing fresh to + // emit, finish and drop the retained input so a post-EOF retarget can't make + // the closure emit again (a still-pending snapshot returns Ready above). + if inner_eof { + self.last_input = None; Poll::Ready(Ok(None)) } else { Poll::Pending @@ -385,10 +395,42 @@ fn best_audio(renditions: &BTreeMap) -> String { #[cfg(test)] mod test { + use std::collections::BTreeMap; + use hang::catalog::{Container, H264, VideoConfig}; use super::*; + /// A one-shot stream: yields its snapshot once, then EOF. + struct Once(Option); + + impl Stream for Once { + type Ext = (); + + fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { + Poll::Ready(Ok(self.0.take())) + } + } + + /// Once upstream ends and the final selected snapshot is emitted, the stream ends + /// rather than parking forever waiting for a post-EOF retarget. + #[test] + fn ends_after_upstream_eof() { + let mut catalog = Catalog::default(); + catalog.video.renditions = BTreeMap::from_iter(vec![vid("only", 640, 360, 500_000)]); + + let mut t = Target::new(Once(Some(catalog))); + assert!(matches!(t.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(Some(_))))); + assert!(matches!(t.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); + + // EOF is terminal: a retarget after the end must not revive the stream. + t.set_video(TargetVideo { + width: Some(320), + ..Default::default() + }); + assert!(matches!(t.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); + } + fn vid(name: &str, w: u32, h: u32, bitrate: u64) -> (String, VideoConfig) { let mut config = VideoConfig::new(H264 { profile: 0x42, diff --git a/rs/moq-mux/src/codec/aac/mod.rs b/rs/moq-mux/src/codec/aac/mod.rs index 0360f6c82..49c5c3fc2 100644 --- a/rs/moq-mux/src/codec/aac/mod.rs +++ b/rs/moq-mux/src/codec/aac/mod.rs @@ -43,79 +43,45 @@ impl Config { /// Parse an AudioSpecificConfig buffer. /// /// Handles basic formats (object_type < 31), extended formats - /// (object_type == 31), and explicit sample rates (freq_index == 15). - /// Any SBR/PS extension bytes after the core fields are consumed. + /// (object_type == 31), and explicit sample rates (freq_index == 15). The + /// fields are bit-packed and not byte-aligned, so a bit reader is required: + /// with an explicit 24-bit rate the channelConfiguration lands mid-byte after + /// it. Any SBR/PS extension bits after the core fields are consumed. pub fn parse(buf: &mut T) -> Result { if buf.remaining() < 2 { return Err(Error::ConfigTooShort); } - // Read first byte - let b0 = buf.get_u8(); - let mut object_type = b0 >> 3; - let freq_index; + let mut reader = BitReader::new(buf); - let (profile, sample_rate, channel_count) = if object_type == 31 { - if buf.remaining() < 2 { - return Err(Error::ExtendedConfigTooShort); - } - // Extended format: next 6 bits are the extended object_type (32-63). - // Bits 5-7 of b0 are the first 3 bits of extended object_type. - let b_ext = buf.get_u8(); - // Bits 0-2 of b_ext are the last 3 bits of extended object_type. - let audio_object_type_ext = ((b0 & 0x07) << 3) | ((b_ext >> 5) & 0x07); - object_type = 32 + audio_object_type_ext; - // Bits 3-6 of b_ext are samplingFrequencyIndex (4 bits). - freq_index = (b_ext >> 1) & 0x0F; - // Bit 0 of b_ext is the first bit of channelConfiguration. - let channel_config_high = b_ext & 0x01; - - // Read next byte for rest of channelConfiguration. - if buf.remaining() < 1 { - return Err(Error::IncompleteConfig); - } - let b1 = buf.get_u8(); - // Bits 5-7 of b1 are the remaining 3 bits of channelConfiguration. - let channel_config = (channel_config_high << 3) | ((b1 >> 5) & 0x07); - - let sample_rate = sample_rate_from_index(freq_index, buf)?; - let channel_count = channel_count_from_config(channel_config); - - if buf.remaining() > 0 { - buf.advance(buf.remaining()); - } + // audioObjectType: 5 bits, escaped to 6 more when it reads 31. + let mut object_type = reader.read(5, Error::ConfigTooShort)? as u8; + if object_type == 31 { + object_type = 32 + reader.read(6, Error::ExtendedConfigTooShort)? as u8; + } - (object_type, sample_rate, channel_count) + // samplingFrequencyIndex: 4 bits; index 15 means an explicit 24-bit rate follows. + let freq_index = reader.read(4, Error::IncompleteConfig)? as u8; + let sample_rate = if freq_index == 15 { + reader.read(24, Error::ExplicitSampleRateTooShort)? } else { - // Standard format: bits 5-7 of b0 are first 3 bits of freq_index. - let mut freq_index_local = (b0 & 0x07) << 1; - - if buf.remaining() < 1 { - return Err(Error::IncompleteConfig); - } - let b1 = buf.get_u8(); - - // Complete frequency index (bit 7 of b1 is bit 0 of freq_index). - freq_index_local |= (b1 >> 7) & 0x01; - freq_index = freq_index_local; - - let channel_config = (b1 >> 3) & 0x0F; - - let sample_rate = sample_rate_from_index(freq_index, buf)?; - let channel_count = channel_count_from_config(channel_config); + *SAMPLE_RATES + .get(freq_index as usize) + .ok_or(Error::UnsupportedSampleRateIndex(freq_index))? + }; - // AudioSpecificConfig can have variable-length extensions (SBR, PS, - // etc.). We've already extracted the essential info; consume any - // remaining bytes to ensure the buffer is properly advanced. - if buf.remaining() > 0 { - buf.advance(buf.remaining()); - } + // channelConfiguration: 4 bits, immediately after the (possibly explicit) rate. + let channel_config = reader.read(4, Error::IncompleteConfig)? as u8; + let channel_count = channel_count_from_config(channel_config); - (object_type, sample_rate, channel_count) - }; + // AudioSpecificConfig can carry variable-length extensions (SBR, PS, etc.). + // We've extracted the essential fields; drain the rest so the buffer is advanced. + if buf.remaining() > 0 { + buf.advance(buf.remaining()); + } Ok(Self { - profile, + profile: object_type, sample_rate, channel_count, }) @@ -166,23 +132,48 @@ impl Config { } } -fn sample_rate_from_index(freq_index: u8, buf: &mut T) -> Result { - const SAMPLE_RATES: [u32; 13] = [ - 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, - ]; +/// The 13 standard AAC sampling frequencies, indexed by samplingFrequencyIndex +/// (ISO 14496-3 Table 1.18). Index 15 is the escape for an explicit 24-bit rate. +const SAMPLE_RATES: [u32; 13] = [ + 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, +]; + +/// MSB-first bit reader that pulls bytes from a [`Buf`] on demand. +/// +/// AudioSpecificConfig is bit-packed: an explicit 24-bit sample rate pushes the +/// following channelConfiguration off byte boundaries, so the fields can't be +/// read a whole byte at a time. +struct BitReader<'a, T: Buf> { + buf: &'a mut T, + current: u8, + bits_left: u8, +} - if freq_index == 15 { - if buf.remaining() < 3 { - return Err(Error::ExplicitSampleRateTooShort); +impl<'a, T: Buf> BitReader<'a, T> { + fn new(buf: &'a mut T) -> Self { + Self { + buf, + current: 0, + bits_left: 0, } - let rate_bytes = [buf.get_u8(), buf.get_u8(), buf.get_u8()]; - return Ok(((rate_bytes[0] as u32) << 16) | ((rate_bytes[1] as u32) << 8) | (rate_bytes[2] as u32)); } - SAMPLE_RATES - .get(freq_index as usize) - .copied() - .ok_or(Error::UnsupportedSampleRateIndex(freq_index)) + /// Read `n` bits (n <= 32) MSB-first, returning `short` if the buffer runs dry. + fn read(&mut self, n: u8, short: Error) -> Result { + let mut value = 0u32; + for _ in 0..n { + if self.bits_left == 0 { + if !self.buf.has_remaining() { + return Err(short); + } + self.current = self.buf.get_u8(); + self.bits_left = 8; + } + self.bits_left -= 1; + value = (value << 1) | u32::from((self.current >> self.bits_left) & 1); + } + Ok(value) + } } /// Map an AAC `channel_config` (ISO 14496-3 Table 1.19) to its real channel count. diff --git a/rs/moq-mux/src/codec/h264/split.rs b/rs/moq-mux/src/codec/h264/split.rs index 21624ab5c..b09d5aa3b 100644 --- a/rs/moq-mux/src/codec/h264/split.rs +++ b/rs/moq-mux/src/codec/h264/split.rs @@ -135,6 +135,13 @@ impl Split { self.maybe_start_frame(pts)?; } Some(Avc3NalType::IdrSlice) => { + // first_mb_in_slice == 0 (ue(v), so the byte-after-header high bit is set) + // marks the first slice of a new picture: close any access unit still open. + // A bare IDR arriving right after a delta picture in the same chunk would + // otherwise fold both into one frame and mis-flag it a keyframe. + if nal.get(1).ok_or(Error::NalTooShort)? & 0x80 != 0 { + self.maybe_start_frame(pts)?; + } // Adopt this keyframe's inline set (dropping any the new GOP no longer // uses), or re-inject the retained set if the keyframe carried none. crate::codec::annexb::reconcile_keyframe_params( @@ -348,6 +355,39 @@ mod tests { ); } + /// A bare IDR arriving right after a delta picture in the same decode chunk must + /// open its own access unit, not fold into the delta's frame. Without closing the + /// open slice on the IDR's first slice, the two AUs merge and the result is + /// mis-flagged as a keyframe. + #[tokio::test(start_paused = true)] + async fn bare_idr_after_delta_splits() { + let sps: &[u8] = &[0x67, 0x42, 0xc0, 0x1f]; + let pps: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; + let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + // P-slice with first_mb_in_slice set (byte 1 high bit), opening a new AU. + let pslice: &[u8] = &[0x61, 0xe0, 0x12, 0x34]; + // A trailing AUD so the bare IDR is a *complete* NAL during decode. + let aud: &[u8] = &[0x09, 0x10]; + + let mut split = Split::new(); + // One chunk: keyframe, a delta picture, then a bare IDR (no inline params). + let frames = split.decode(&annexb(&[sps, pps, idr, pslice, idr, aud]), ts()).unwrap(); + + // The keyframe and the delta both completed; the second IDR's AU is still buffered. + assert_eq!(frames.len(), 2); + assert!(frames[0].keyframe, "first AU is the keyframe"); + assert!(!frames[1].keyframe, "the delta picture must not be flagged a keyframe"); + // The delta frame holds only its own slice, not a merged keyframe. + assert_eq!(frames[1].payload.as_ref(), annexb(&[pslice]).freeze().as_ref()); + + // Flushing closes the bare IDR as its own self-contained keyframe (params + // re-injected). The trailing AUD opens a fresh slice-less AU that is dropped. + let tail = split.flush(ts()).unwrap(); + assert_eq!(tail.len(), 1); + assert!(tail[0].keyframe); + assert_eq!(tail[0].payload.as_ref(), annexb(&[sps, pps, idr]).freeze().as_ref()); + } + /// A keyframe that presents a smaller parameter set than a prior one reinits /// the retained set: the dropped PPS must not be re-injected on later bare /// keyframes. diff --git a/rs/moq-mux/src/codec/h265/split.rs b/rs/moq-mux/src/codec/h265/split.rs index e4bbf5b62..d9ee61148 100644 --- a/rs/moq-mux/src/codec/h265/split.rs +++ b/rs/moq-mux/src/codec/h265/split.rs @@ -148,6 +148,13 @@ impl Split { | NALUnitType::BlaWRadl | NALUnitType::BlaWLp | NALUnitType::CraNut => { + // first_slice_segment_in_pic_flag (bit 7 of the third byte, after the + // 2-byte header) marks the first slice of a new picture: close any access + // unit still open. A bare IDR arriving right after a delta picture in the + // same chunk would otherwise fold both into one frame and mis-flag it a keyframe. + if nal.get(2).ok_or(Error::NalTooShort)? & 0x80 != 0 { + self.maybe_start_frame(pts)?; + } // Adopt this keyframe's inline set (dropping any the new GOP no longer // uses), or re-inject the retained set if the keyframe carried none. crate::codec::annexb::reconcile_keyframe_params( @@ -334,6 +341,37 @@ mod tests { ); } + /// A bare IDR arriving right after a delta picture in the same decode chunk must + /// open its own access unit, not fold into the delta's frame. Without closing the + /// open slice on the IDR's first slice, the two AUs merge and the result is + /// mis-flagged as a keyframe. + #[tokio::test(start_paused = true)] + async fn bare_idr_after_delta_splits() { + // TrailR (type 1) with first_slice_segment_in_pic_flag set (byte 2 high bit). + const TRAIL: &[u8] = &[0x02, 0x01, 0x80, 0x33]; + const AUD: &[u8] = &[0x46, 0x01, 0x50]; // AudNut (type 35) + + let mut split = Split::new(); + // One chunk: keyframe, a delta picture, then a bare IDR (no inline params). + let frames = split + .decode(&annexb(&[VPS, SPS, PPS, IDR, TRAIL, IDR, AUD]), ts()) + .unwrap(); + + assert_eq!(frames.len(), 2); + assert!(frames[0].keyframe, "first AU is the keyframe"); + assert!(!frames[1].keyframe, "the delta picture must not be flagged a keyframe"); + assert_eq!(frames[1].payload.as_ref(), annexb(&[TRAIL]).freeze().as_ref()); + + // Flushing closes the bare IDR as its own self-contained keyframe. + let tail = split.flush(ts()).unwrap(); + assert_eq!(tail.len(), 1); + assert!(tail[0].keyframe); + assert_eq!( + tail[0].payload.as_ref(), + annexb(&[VPS, SPS, PPS, IDR]).freeze().as_ref() + ); + } + /// A keyframe that presents a smaller parameter set than a prior one reinits /// the retained set: the dropped PPS must not be re-injected on later bare /// keyframes. diff --git a/rs/moq-mux/src/codec/opus/mod.rs b/rs/moq-mux/src/codec/opus/mod.rs index 035a397a0..08c2f3e18 100644 --- a/rs/moq-mux/src/codec/opus/mod.rs +++ b/rs/moq-mux/src/codec/opus/mod.rs @@ -15,11 +15,18 @@ const OPUS_HEAD: u64 = u64::from_be_bytes(*b"OpusHead"); #[derive(Debug, Clone, thiserror::Error)] #[non_exhaustive] pub enum Error { + /// The OpusHead packet was shorter than the 19-byte minimum (RFC 7845 §5.1). #[error("OpusHead must be at least 19 bytes")] HeadTooShort, + /// The packet did not start with the `OpusHead` magic signature. #[error("invalid OpusHead signature")] InvalidSignature, + + /// [`Config::encode`] was asked to emit an OpusHead for a channel count other + /// than mono or stereo; channel mapping family 0 only covers 1 or 2 channels. + #[error("channel mapping family 0 only supports mono/stereo (got {0} channels)")] + UnsupportedChannelCount(u32), } pub type Result = std::result::Result; @@ -64,15 +71,14 @@ impl Config { /// Encode the minimal OpusHead packet (19 bytes; channel mapping family /// 0, zero pre-skip and gain). /// - /// Panics if `channel_count > 2` — mapping family 0 is only defined for - /// mono/stereo per RFC 7845 §5.1. Multi-channel streams need family 1 - /// with a channel mapping table, which this helper does not emit. - pub fn encode(&self) -> Bytes { - assert!( - self.channel_count <= 2, - "OpusHead mapping family 0 only supports mono/stereo (got channel_count={})", - self.channel_count - ); + /// Errors with [`Error::UnsupportedChannelCount`] unless `channel_count` is 1 + /// or 2 — mapping family 0 is only defined for mono/stereo per RFC 7845 §5.1. + /// Multi-channel streams need family 1 with a channel mapping table, which + /// this helper does not emit. + pub fn encode(&self) -> Result { + if !(1..=2).contains(&self.channel_count) { + return Err(Error::UnsupportedChannelCount(self.channel_count)); + } let mut head = Vec::with_capacity(19); head.extend_from_slice(b"OpusHead"); head.push(1); // version @@ -81,7 +87,7 @@ impl Config { head.extend_from_slice(&self.sample_rate.to_le_bytes()); head.extend_from_slice(&0i16.to_le_bytes()); // output gain head.push(0); // channel mapping family (0 = mono/stereo) - Bytes::from(head) + Ok(Bytes::from(head)) } } @@ -95,7 +101,7 @@ mod tests { sample_rate: 48000, channel_count: 2, }; - let encoded = cfg.encode(); + let encoded = cfg.encode().unwrap(); assert_eq!(encoded.len(), 19); let parsed = Config::parse(&mut encoded.as_ref()).unwrap(); assert_eq!(parsed.sample_rate, 48000); @@ -109,18 +115,20 @@ mod tests { channel_count: 1, } .encode() + .unwrap() .to_vec(); bytes[0] = b'X'; assert!(Config::parse(&mut bytes.as_slice()).is_err()); } #[test] - #[should_panic(expected = "mapping family 0")] - fn encode_panics_for_multichannel() { - Config { + fn encode_rejects_multichannel() { + let err = Config { sample_rate: 48000, channel_count: 6, } - .encode(); + .encode() + .unwrap_err(); + assert!(matches!(err, Error::UnsupportedChannelCount(6))); } } diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index 95b00ea3e..15cb7645c 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -226,12 +226,21 @@ impl Consumer { // Still blocked on this group, don't skip it yet. Poll::Pending => break, Poll::Ready(Err(e)) => { - // The group was dropped/aborted -- typically it aged out of the relay - // cache (`Error::Old`) while we weren't reading it. Any sequences - // between it and the next buffered group were evicted alongside it, so - // jump straight to that group instead of stepping one-by-one and then - // blocking on a sequence gap of groups that will never arrive. - tracing::warn!(error = ?e, "current group dropped; skipping to next buffered group"); + // Tell a relay group eviction/abort (skip) from a payload decode error + // (propagate). The moq_net group's own terminal state is the source of + // truth: an evicted/aborted group reports the transport error from + // poll_finished, while a malformed payload leaves the group live or + // cleanly finished. A decode error is real and the caller must see it, + // not have the group silently dropped. + if !group.poll_aborted(waiter) { + return Poll::Ready(Err(e)); + } + // The group aged out of the relay cache (`Error::Old`) or was otherwise + // aborted. Any sequences between it and the next buffered group were + // evicted alongside it, so jump straight to that group instead of + // stepping one-by-one and then blocking on a sequence gap of groups + // that will never arrive. + tracing::warn!(error = ?e, "current group evicted; skipping to next buffered group"); self.pending.pop_front(); self.current = self.pending.front().map_or(self.current + 1, |g| g.sequence); } @@ -618,6 +627,15 @@ impl GroupBuffer { Poll::Pending } + + /// True if the group's moq_net stream was reset/aborted (evicted, `Old`, + /// cancelled, ...), as opposed to still live or cleanly finished. Lets the + /// consumer tell a transport eviction from a payload decode error: the former + /// surfaces as a terminal transport error from `poll_finished`, the latter + /// leaves the group readable or finished. + fn poll_aborted(&mut self, waiter: &kio::Waiter) -> bool { + matches!(self.group.poll_finished(waiter), Poll::Ready(Err(_))) + } } impl std::ops::Deref for GroupBuffer { @@ -1374,6 +1392,75 @@ mod tests { assert!(reached.is_ok(), "consumer hung on a missing sequence on a live track"); } + // ---- Decode errors ---- + + /// A container that decodes each frame's payload as an 8-byte LE microsecond + /// timestamp, but treats a `FAIL` payload as a malformed frame. Lets a test put a + /// decodable frame first (so startup selects the group) and a decode failure after. + struct FailingDecode; + + impl ContainerTrait for FailingDecode { + type Error = crate::Error; + + fn write(&self, group: &mut moq_net::GroupProducer, frames: &[Frame]) -> Result<(), Self::Error> { + for frame in frames { + group.write_frame_now(frame.payload.clone())?; + } + Ok(()) + } + + fn poll_read( + &self, + group: &mut moq_net::GroupConsumer, + waiter: &kio::Waiter, + ) -> Poll>, Self::Error>> { + use bytes::Buf; + + let Some(mut data) = ready!(group.poll_read_frame(waiter)?) else { + return Poll::Ready(Ok(None)); + }; + if data.as_ref() == b"FAIL" { + return Poll::Ready(Err(crate::Error::UnknownFormat("malformed payload".into()))); + } + Poll::Ready(Ok(Some(vec![Frame { + timestamp: ts(data.get_u64_le()), + payload: Bytes::new(), + keyframe: false, + duration: None, + }]))) + } + } + + /// A decode error on a cleanly-finished group must propagate to the caller, not be + /// mistaken for a relay eviction and silently skipped. Eviction-skip only fires when + /// the group's stream was actually aborted. + #[tokio::test] + async fn decode_error_propagates() { + tokio::time::pause(); + let mut track = track_producer("test", None); + let consumer_track = track.subscribe(None); + let mut consumer = Consumer::new(consumer_track, FailingDecode); + + // A decodable frame first (so startup selects the group), then a malformed one. + let mut group = track.create_group(moq_net::GroupInfo { sequence: 0 }).unwrap(); + group.write_frame_now(Bytes::from(0u64.to_le_bytes().to_vec())).unwrap(); + group.write_frame_now(Bytes::from_static(b"FAIL")).unwrap(); + group.finish().unwrap(); + track.finish().unwrap(); + + // The first frame decodes; the malformed second frame must surface as an error. + let first = consumer.read().await; + assert!(matches!(first, Ok(Some(_))), "first frame should decode, got {first:?}"); + + let second = tokio::time::timeout(Duration::from_millis(200), consumer.read()) + .await + .expect("consumer hung on a decode error"); + assert!( + matches!(second, Err(crate::Error::UnknownFormat(_))), + "decode error must propagate, got {second:?}" + ); + } + // ---- Frame Decoding ---- #[tokio::test] diff --git a/rs/moq-mux/src/container/flv/export_test.rs b/rs/moq-mux/src/container/flv/export_test.rs index bf484f62e..160d56d7c 100644 --- a/rs/moq-mux/src/container/flv/export_test.rs +++ b/rs/moq-mux/src/container/flv/export_test.rs @@ -194,7 +194,8 @@ fn synth_enhanced_flv() -> Vec { sample_rate: 48000, channel_count: 2, } - .encode(); + .encode() + .unwrap(); let mut out = Vec::new(); out.extend_from_slice(b"FLV"); diff --git a/rs/moq-mux/src/container/flv/import_test.rs b/rs/moq-mux/src/container/flv/import_test.rs index 15c947b86..ced7d600f 100644 --- a/rs/moq-mux/src/container/flv/import_test.rs +++ b/rs/moq-mux/src/container/flv/import_test.rs @@ -186,7 +186,8 @@ async fn import_enhanced_opus() { sample_rate: 48000, channel_count: 2, } - .encode(); + .encode() + .unwrap(); let mut out = Vec::new(); out.extend_from_slice(b"FLV"); diff --git a/rs/moq-mux/src/container/fmp4/export.rs b/rs/moq-mux/src/container/fmp4/export.rs index fec0416f9..ce4981ace 100644 --- a/rs/moq-mux/src/container/fmp4/export.rs +++ b/rs/moq-mux/src/container/fmp4/export.rs @@ -11,6 +11,7 @@ use crate::catalog::Stream; use crate::container::ExportSource; use crate::container::Frame; use crate::container::fmp4::Error; +use moq_net::Timestamp; /// Subscribe to a moq broadcast and produce a single fMP4 / CMAF byte stream. /// @@ -249,7 +250,7 @@ impl Export { let flush_before = should_flush(track, &frame, frag, has_video_track); if flush_before { let frames = std::mem::take(&mut track.buffer); - let fragment = emit_fragment(track, frames)?; + let fragment = emit_fragment(track, frames, Some(&frame))?; // The flushed run is done; the incoming frame opens the next buffer. track.buffer_independent = frame.keyframe; track.buffer.push(frame); @@ -281,7 +282,7 @@ impl Export { if let Some(name) = flushable { let track = self.tracks.get_mut(&name).unwrap(); let frames = std::mem::take(&mut track.buffer); - let fragment = emit_fragment(track, frames)?; + let fragment = emit_fragment(track, frames, None)?; return Poll::Ready(Ok(Some(fragment))); } @@ -551,10 +552,11 @@ fn encode_fragment(track: &mut Fmp4Track, frames: Vec) -> Result { } /// Encode a buffered run and wrap it with the metadata a segmenting consumer needs. -fn emit_fragment(track: &mut Fmp4Track, frames: Vec) -> Result { +fn emit_fragment(track: &mut Fmp4Track, frames: Vec, successor: Option<&Frame>) -> Result { // Audio has no keyframes, so every audio fragment is independent; video is // independent only when its buffer opened on a keyframe (a GOP boundary). let independent = !track.is_video || track.buffer_independent; + let frames = infer_missing_durations(frames, successor, track.default_frame); let duration = fragment_seconds(&frames, track.default_frame); let data = encode_fragment(track, frames)?; Ok(Fragment { @@ -575,7 +577,10 @@ fn fragment_seconds(frames: &[Frame], default_frame: Duration) -> f64 { if frames.is_empty() { return 0.0; } - if frames.iter().all(|f| f.duration.is_some()) { + if frames + .iter() + .all(|f| f.duration.is_some_and(|duration| !duration.is_zero())) + { return frames .iter() .map(|f| Duration::from(f.duration.unwrap())) @@ -592,6 +597,48 @@ fn fragment_seconds(frames: &[Frame], default_frame: Duration) -> f64 { ((max - min) + default_frame).as_secs_f64() } +fn infer_missing_durations(mut frames: Vec, successor: Option<&Frame>, default_frame: Duration) -> Vec { + let infer_from_pts = pts_monotonic(&frames, successor); + // Express the fallback at the frames' own timescale so it matches the durations derived from + // their timestamps (a `Timestamp` carries its scale, and `try_from(Duration)` is nanosecond-scale). + let fallback = frames.first().map(|f| f.timestamp.scale()).and_then(|scale| { + Timestamp::try_from(default_frame) + .ok() + .and_then(|t| t.convert(scale).ok()) + }); + + for i in 0..frames.len() { + if frames[i].duration.is_some_and(|duration| !duration.is_zero()) { + continue; + } + + frames[i].duration = infer_from_pts + .then(|| next_timestamp(&frames, successor, i)) + .flatten() + .and_then(|timestamp| timestamp.checked_sub(frames[i].timestamp).ok()) + .filter(|duration| !duration.is_zero()) + .or(fallback); + } + + frames +} + +fn pts_monotonic(frames: &[Frame], successor: Option<&Frame>) -> bool { + let frames_monotonic = frames.windows(2).all(|pair| pair[1].timestamp >= pair[0].timestamp); + let successor_monotonic = match (frames.last(), successor) { + (Some(last), Some(successor)) => successor.timestamp >= last.timestamp, + _ => true, + }; + frames_monotonic && successor_monotonic +} + +fn next_timestamp(frames: &[Frame], successor: Option<&Frame>, index: usize) -> Option { + frames + .get(index + 1) + .map(|next| next.timestamp) + .or_else(|| successor.map(|next| next.timestamp)) +} + fn catalog_timescale_video(config: &VideoConfig) -> u64 { match &config.container { Container::Cmaf { init, .. } => { @@ -618,3 +665,69 @@ fn parse_timescale_from_init(init: &[u8]) -> Result { } Err(Error::NoMoov.into()) } + +#[cfg(test)] +mod tests { + use bytes::Bytes; + + use super::*; + + fn ts(micros: u64) -> Timestamp { + Timestamp::from_micros(micros).unwrap() + } + + fn frame(timestamp_us: u64, duration_us: Option) -> Frame { + Frame { + timestamp: ts(timestamp_us), + duration: duration_us.map(ts), + payload: Bytes::from_static(&[0xDE, 0xAD]), + keyframe: false, + } + } + + #[test] + fn infer_missing_durations_uses_default_for_trailing_sample() { + let frames = infer_missing_durations( + vec![frame(0, Some(0)), frame(41_667, None), frame(83_334, None)], + None, + Duration::from_millis(33), + ); + + assert_eq!(frames[0].duration, Some(ts(41_667))); + assert_eq!(frames[1].duration, Some(ts(41_667))); + assert_eq!(frames[2].duration, Some(ts(33_000))); + assert_eq!(fragment_seconds(&frames, Duration::from_millis(33)), 0.116334); + } + + #[test] + fn infer_missing_duration_uses_default_for_single_frame() { + let frames = infer_missing_durations(vec![frame(83_333, Some(0))], None, Duration::from_millis(40)); + + assert_eq!(frames[0].duration, Some(ts(40_000))); + assert_eq!(fragment_seconds(&frames, Duration::from_millis(40)), 0.04); + } + + #[test] + fn infer_trailing_duration_from_successor_frame() { + let successor = frame(83_334, None); + let frames = infer_missing_durations(vec![frame(41_667, None)], Some(&successor), Duration::from_millis(33)); + + assert_eq!(frames[0].duration, Some(ts(41_667))); + assert_eq!(fragment_seconds(&frames, Duration::from_millis(33)), 0.041667); + } + + #[test] + fn infer_missing_durations_avoids_non_monotonic_pts() { + let successor = frame(66_000, None); + let frames = infer_missing_durations( + vec![frame(0, None), frame(99_000, None), frame(33_000, None)], + Some(&successor), + Duration::from_millis(33), + ); + + assert_eq!(frames[0].duration, Some(ts(33_000))); + assert_eq!(frames[1].duration, Some(ts(33_000))); + assert_eq!(frames[2].duration, Some(ts(33_000))); + assert_eq!(fragment_seconds(&frames, Duration::from_millis(33)), 0.099); + } +} diff --git a/rs/moq-mux/src/container/fmp4/import.rs b/rs/moq-mux/src/container/fmp4/import.rs index 47e851fbd..763d67403 100644 --- a/rs/moq-mux/src/container/fmp4/import.rs +++ b/rs/moq-mux/src/container/fmp4/import.rs @@ -443,6 +443,8 @@ impl Import { let mut min_timestamp = None; let mut max_timestamp = None; let mut contains_keyframe = false; + let total_samples: usize = traf.trun.iter().map(|t| t.entries.len()).sum(); + let mut sample_index = 0usize; for trun in &traf.trun { let tfhd = &traf.tfhd; @@ -472,11 +474,19 @@ impl Import { .unwrap_or(tfhd.default_sample_flags.unwrap_or(default_sample_flags)); let duration = entry .duration - .unwrap_or(tfhd.default_sample_duration.unwrap_or(default_sample_duration)); + .or(tfhd.default_sample_duration) + .or(Some(default_sample_duration)) + .filter(|duration| *duration != 0); let size = entry .size .unwrap_or(tfhd.default_sample_size.unwrap_or(default_sample_size)) as usize; + // A non-final sample with no resolvable duration leaves the rest of the + // fragment's DTS ambiguous, so reject it rather than collapse timestamps. + if duration.is_none() && sample_index + 1 != total_samples { + return Err(Error::MissingSampleDuration.into()); + } + let pts = (dts as i64 + entry.cts.unwrap_or_default() as i64) as u64; // Preserve the fmp4 track's native timescale so a passthrough re-emit // doesn't go through a lossy microsecond detour. @@ -513,8 +523,11 @@ impl Import { track.last_timestamp = Some(timestamp); - dts += duration as u64; + if let Some(duration) = duration { + dts += duration as u64; + } offset += size; + sample_index += 1; } } @@ -551,9 +564,19 @@ impl Import { // and ensuring trun.data_offset is Some(...) reserves 4 bytes per trun. for traf_mut in &mut adjusted_moof.traf { traf_mut.tfhd.base_data_offset = None; + // A zero default/sample duration is "unknown", not "instantaneous": drop it so + // the re-emitted fragment carries no bogus zero that a decoder would honor. + if traf_mut.tfhd.default_sample_duration == Some(0) { + traf_mut.tfhd.default_sample_duration = None; + } for trun_mut in &mut traf_mut.trun { // Reserve the data_offset field; the real value is filled in below. trun_mut.data_offset = Some(0); + for entry in &mut trun_mut.entries { + if entry.duration == Some(0) { + entry.duration = None; + } + } } } diff --git a/rs/moq-mux/src/container/fmp4/import_test.rs b/rs/moq-mux/src/container/fmp4/import_test.rs index 3f0054cbf..bfef597a6 100644 --- a/rs/moq-mux/src/container/fmp4/import_test.rs +++ b/rs/moq-mux/src/container/fmp4/import_test.rs @@ -253,3 +253,49 @@ async fn test_msf_catalog_roundtrip() { assert_eq!(audio.channel_count, 2); assert!(matches!(audio.container, Container::Cmaf { .. })); } + +// ---- Sample-duration handling in decode() ---- + +fn scale() -> moq_net::Timescale { + moq_net::Timescale::new(1_000_000).unwrap() +} + +fn sample(timestamp_us: u64, keyframe: bool, duration_us: Option) -> crate::container::Frame { + crate::container::Frame { + timestamp: moq_net::Timestamp::from_micros(timestamp_us).unwrap(), + payload: bytes::Bytes::from_static(&[0xDE, 0xAD]), + keyframe, + duration: duration_us.map(|d| moq_net::Timestamp::from_micros(d).unwrap()), + } +} + +/// A multi-sample fragment whose non-final sample carries no duration can't have its +/// DTS reconstructed, so decode rejects it rather than collapsing the timestamps. +#[test] +fn decode_rejects_durationless_multisample() { + let frames = vec![sample(0, true, None), sample(33_000, false, None)]; + let frag = super::encode_fragment(1, scale(), 0, &frames).unwrap(); + let err = super::decode(frag, scale()).unwrap_err(); + assert!(matches!(err, super::Error::MissingSampleDuration), "got {err:?}"); +} + +/// A single-sample fragment needs no duration (nothing follows it), so it still decodes. +#[test] +fn decode_single_sample_no_duration_ok() { + let frag = super::encode_fragment(1, scale(), 0, &[sample(0, true, None)]).unwrap(); + let out = super::decode(frag, scale()).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].timestamp.as_micros(), 0); +} + +/// With the durations the producer now backfills, every sample's DTS round-trips +/// through a multi-sample fragment. +#[test] +fn decode_multisample_with_durations_roundtrips() { + let frames = vec![sample(0, true, Some(33_000)), sample(33_000, false, Some(33_000))]; + let frag = super::encode_fragment(1, scale(), 0, &frames).unwrap(); + let out = super::decode(frag, scale()).unwrap(); + assert_eq!(out.len(), 2); + assert_eq!(out[0].timestamp.as_micros(), 0); + assert_eq!(out[1].timestamp.as_micros(), 33_000); +} diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index 5093d1a1a..f1e358cf4 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -128,6 +128,9 @@ pub enum Error { #[error("audio codec {0} needs a description (AudioSpecificConfig) to synthesize a CMAF init")] MissingAudioDescription(String), + + #[error("multi-sample fragment has a non-final sample with no duration; DTS is unrecoverable")] + MissingSampleDuration, } impl From for Error { @@ -227,9 +230,15 @@ pub(crate) fn decode(data: Bytes, timescale: moq_net::Timescale) -> Result Result Result Export { /// /// `catalog` is any [`Stream`] of catalog snapshots, typically a /// [`catalog::Consumer`](crate::catalog::Consumer) directly, or wrapped in - /// [`catalog::Filter`](crate::catalog::Filter) / - /// [`catalog::Target`](crate::catalog::Target) to narrow the rendition set. + /// [`catalog::Filter`](crate::catalog::Filter) to narrow the rendition set. pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: S) -> Self { Self { broadcast, @@ -583,7 +582,7 @@ fn build_audio_track_entry(track_number: u64, config: &AudioConfig) -> Result), + + /// Building the Opus codec-private OpusHead for an audio track failed. + #[error(transparent)] + Opus(#[from] crate::codec::opus::Error), } impl From for Error { diff --git a/rs/moq-mux/src/container/producer.rs b/rs/moq-mux/src/container/producer.rs index b0f629e68..f9b54189d 100644 --- a/rs/moq-mux/src/container/producer.rs +++ b/rs/moq-mux/src/container/producer.rs @@ -150,22 +150,30 @@ impl Producer { /// Flush any buffered frames into the current group without closing it. /// - /// `next`, when given, is the timestamp of the frame that rolled the group over - /// (the next keyframe). The buffer's last frame is the only sample whose successor - /// wasn't visible when it arrived, so we backfill its duration from `next` here. - /// This adds no latency: that frame is already in hand. Containers that don't use - /// per-frame durations (Legacy, LOC) ignore it. + /// Backfills the per-sample duration the source didn't provide. A CMAF fragment + /// reconstructs each sample's DTS by accumulating durations, so every non-final + /// sample packed into one fragment needs one or the decoder collapses their + /// timestamps. Frames are in decode order, so a sample's duration is the gap to the + /// next buffered sample; the final sample borrows `next` (the timestamp of the + /// keyframe that rolled the group over), which is already in hand so this adds no + /// latency. Frames that already carry a duration (e.g. fMP4 passthrough) keep it, + /// and a backwards gap (a B-frame whose successor presents earlier) is left unset. + /// Containers that don't use per-frame durations (Legacy, LOC) ignore the field. fn flush(&mut self, next: Option) -> Result<(), C::Error> { if self.buffer.is_empty() { return Ok(()); } - if let Some(next) = next - && let Some(last) = self.buffer.last_mut() - && last.duration.is_none() - && let Ok(duration) = next.checked_sub(last.timestamp) - { - last.duration = Some(duration); + for i in 0..self.buffer.len() { + if self.buffer[i].duration.is_some() { + continue; + } + let boundary = self.buffer.get(i + 1).map(|f| f.timestamp).or(next); + if let Some(boundary) = boundary + && let Ok(duration) = boundary.checked_sub(self.buffer[i].timestamp) + { + self.buffer[i].duration = Some(duration); + } } let group = match &mut self.group { @@ -363,7 +371,7 @@ mod tests { /// The keyframe that rolls a group over backfills the duration of the previous /// group's last frame, without buffering an extra frame. #[tokio::test] - async fn keyframe_backfills_last_frame_duration() { + async fn keyframe_backfills_batched_durations() { let track = track_producer( "test", moq_net::TrackInfo::default().with_timescale(hang::container::TIMESCALE), @@ -379,9 +387,9 @@ mod tests { let writes = recording.0.borrow(); let group0 = &writes[0]; assert_eq!(group0.len(), 2); - // Last frame's duration backfilled from the next keyframe: 66ms - 33ms. + // The first sample's duration is the gap to the next buffered sample: 33ms - 0. + assert_eq!(group0[0].duration, Some(Timestamp::from_micros(33_000).unwrap())); + // The last sample's duration is backfilled from the next keyframe: 66ms - 33ms. assert_eq!(group0[1].duration, Some(Timestamp::from_micros(33_000).unwrap())); - // The earlier frame keeps None; only the trailing sample needs the boundary. - assert_eq!(group0[0].duration, None); } } diff --git a/rs/moq-native/Cargo.toml b/rs/moq-native/Cargo.toml index be80e2698..bdb66ab6e 100644 --- a/rs/moq-native/Cargo.toml +++ b/rs/moq-native/Cargo.toml @@ -19,7 +19,7 @@ doctest = false default = ["noq", "aws-lc-rs", "websocket", "tcp", "uds"] quinn = ["dep:quinn", "dep:web-transport-quinn", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "watch"] noq = ["dep:web-transport-noq", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "watch"] -quiche = ["dep:web-transport-quiche", "dep:rcgen"] +quiche = ["dep:web-transport-quiche", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki"] # Filesystem watcher for hot-reloading on-disk TLS certs/keys; the QUIC backends imply it. watch = ["dep:notify"] # The QUIC backends pull in their crypto provider from these features (default-features diff --git a/rs/moq-native/src/client.rs b/rs/moq-native/src/client.rs index e5482b5c3..43617e990 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -511,29 +511,59 @@ mod tests { let mut config: ClientConfig = toml::from_str(toml).unwrap(); assert_eq!(config.tls.disable_verify, Some(true)); - // Simulate: TOML loaded, then CLI args re-applied (no --tls-disable-verify flag). + // Simulate: TOML loaded, then CLI args re-applied (no --client-tls-disable-verify flag). config.update_from(["test"]); assert_eq!(config.tls.disable_verify, Some(true)); } #[test] fn test_cli_disable_verify_flag() { - let config = ClientConfig::parse_from(["test", "--tls-disable-verify"]); + let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify"]); assert_eq!(config.tls.disable_verify, Some(true)); } #[test] fn test_cli_disable_verify_explicit_false() { - let config = ClientConfig::parse_from(["test", "--tls-disable-verify=false"]); + let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=false"]); assert_eq!(config.tls.disable_verify, Some(false)); } #[test] fn test_cli_disable_verify_explicit_true() { - let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true"]); + let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=true"]); assert_eq!(config.tls.disable_verify, Some(true)); } + #[test] + fn test_cli_deprecated_tls_flags_fold_into_canonical() { + // The bare --tls-* forms are deprecated. They parse into a hidden field and + // fold into the canonical values via the effective_* accessors build() uses, + // so they keep working without touching the public Client fields. + let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true", "--tls-fingerprint", "abcd1234"]); + assert_eq!( + config.tls.disable_verify, None, + "deprecated flag must not set the canonical field" + ); + assert_eq!(config.tls.effective_disable_verify(), Some(true)); + assert_eq!(config.tls.effective_fingerprint(), vec!["abcd1234"]); + } + + #[test] + fn test_canonical_tls_flag_wins_over_deprecated() { + // Both spellings given: canonical wins for scalar options, vecs concatenate. + let config = ClientConfig::parse_from([ + "test", + "--client-tls-disable-verify=false", + "--tls-disable-verify=true", + "--client-tls-fingerprint", + "aaaa", + "--tls-fingerprint", + "bbbb", + ]); + assert_eq!(config.tls.effective_disable_verify(), Some(false)); + assert_eq!(config.tls.effective_fingerprint(), vec!["aaaa", "bbbb"]); + } + #[test] fn test_cli_no_disable_verify() { let config = ClientConfig::parse_from(["test"]); @@ -549,7 +579,7 @@ mod tests { let mut config: ClientConfig = toml::from_str(toml).unwrap(); assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]); - // Simulate: TOML loaded, then CLI args re-applied (no --tls-fingerprint flag). + // Simulate: TOML loaded, then CLI args re-applied (no --client-tls-fingerprint flag). config.update_from(["test"]); assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]); } @@ -566,7 +596,7 @@ mod tests { #[test] fn test_cli_fingerprint() { - let config = ClientConfig::parse_from(["test", "--tls-fingerprint", "abcd1234"]); + let config = ClientConfig::parse_from(["test", "--client-tls-fingerprint", "abcd1234"]); assert_eq!(config.tls.fingerprint, vec!["abcd1234"]); } diff --git a/rs/moq-native/src/noq.rs b/rs/moq-native/src/noq.rs index 724f830be..a43320324 100644 --- a/rs/moq-native/src/noq.rs +++ b/rs/moq-native/src/noq.rs @@ -119,6 +119,8 @@ type Result = std::result::Result; pub(crate) struct NoqClient { pub quic: noq::Endpoint, pub transport: Arc, + /// Whether an `http://` URL may bootstrap a pin (see [crate::tls::Client::allows_http_bootstrap]). + pub http_bootstrap: bool, pub versions: moq_net::Versions, } @@ -149,6 +151,7 @@ impl NoqClient { Ok(Self { quic, transport, + http_bootstrap: config.tls.allows_http_bootstrap(), versions: config.versions(), }) } @@ -172,25 +175,34 @@ impl NoqClient { let ip = crate::util::pick_addr(addrs, local).ok_or(Error::NoDnsEntries)?; if url.scheme() == "http" { - // Perform a HTTP request to fetch the certificate fingerprint. - let mut fingerprint = url.clone(); - fingerprint.set_path("/certificate.sha256"); - fingerprint.set_query(None); - fingerprint.set_fragment(None); - - tracing::warn!(url = %fingerprint, "performing insecure HTTP request for certificate"); - - let resp = reqwest::get(fingerprint.as_str()) - .await - .map_err(Error::FetchFingerprint)? - .error_for_status() - .map_err(Error::FingerprintStatus)?; - - let fingerprint = resp.text().await.map_err(Error::ReadFingerprint)?; - let fingerprint = hex::decode(fingerprint.trim())?; - - let verifier = FingerprintVerifier::new(config.crypto_provider().clone(), vec![fingerprint]); - config.dangerous().set_certificate_verifier(Arc::new(verifier)); + // Insecure per-connection bootstrap: only honored when no stronger + // verification is configured, so an attacker controlling the plaintext + // fetch can't weaken an explicit pin or re-enable disabled verification. + if self.http_bootstrap { + // Perform a HTTP request to fetch the certificate fingerprint. + let mut fingerprint = url.clone(); + fingerprint.set_path("/certificate.sha256"); + fingerprint.set_query(None); + fingerprint.set_fragment(None); + + tracing::warn!(url = %fingerprint, "performing insecure HTTP request for certificate"); + + let resp = reqwest::get(fingerprint.as_str()) + .await + .map_err(Error::FetchFingerprint)? + .error_for_status() + .map_err(Error::FingerprintStatus)?; + + let fingerprint = resp.text().await.map_err(Error::ReadFingerprint)?; + let fingerprint = hex::decode(fingerprint.trim())?; + + let verifier = FingerprintVerifier::new(config.crypto_provider().clone(), vec![fingerprint]); + config.dangerous().set_certificate_verifier(Arc::new(verifier)); + } else { + tracing::warn!( + "ignoring insecure http:// fingerprint bootstrap; using the configured TLS verification" + ); + } url.set_scheme("https").expect("failed to set scheme"); } diff --git a/rs/moq-native/src/quiche.rs b/rs/moq-native/src/quiche.rs index df4e2474d..d51791189 100644 --- a/rs/moq-native/src/quiche.rs +++ b/rs/moq-native/src/quiche.rs @@ -21,9 +21,27 @@ pub enum Error { #[error("invalid DNS name")] InvalidDnsName, + /// No longer returned: the `http://` scheme now fetches and pins the + /// certificate fingerprint instead of failing. + #[deprecated(note = "fingerprint verification over http:// is now supported; this is never returned")] #[error("fingerprint verification (http:// scheme) is not supported with the quiche backend")] FingerprintUnsupported, + #[error("failed to fetch certificate fingerprint")] + FetchFingerprint(#[source] reqwest::Error), + + #[error("certificate fingerprint request failed")] + FingerprintStatus(#[source] reqwest::Error), + + #[error("failed to read certificate fingerprint")] + ReadFingerprint(#[source] reqwest::Error), + + #[error("invalid certificate fingerprint")] + InvalidFingerprint(#[source] hex::FromHexError), + + #[error("certificate fingerprint must be 32 bytes (SHA-256), got {0}")] + FingerprintLength(usize), + #[error("url scheme must be 'https', 'moqt', or 'moql'")] InvalidScheme, @@ -86,32 +104,52 @@ type Result = std::result::Result; #[derive(Clone)] pub(crate) struct QuicheClient { pub bind: net::SocketAddr, - pub disable_verify: bool, + /// Resolved server-verification policy, shared with the other backends. + pub verification: crate::tls::Verification, + /// Whether an `http://` URL may bootstrap a pin (see [crate::tls::Client::allows_http_bootstrap]). + pub http_bootstrap: bool, pub max_streams: u64, pub versions: moq_net::Versions, } impl QuicheClient { pub fn new(config: &ClientConfig) -> Result { - if !config.tls.root.is_empty() { - tracing::warn!("--tls-root is not supported with the quiche backend; system roots will be used"); - } - Ok(Self { bind: config.bind, - disable_verify: config.tls.disable_verify.unwrap_or_default(), + verification: config.tls.verification()?, + http_bootstrap: config.tls.allows_http_bootstrap(), max_streams: config.max_streams.unwrap_or(crate::DEFAULT_MAX_STREAMS), versions: config.versions(), }) } pub async fn connect(&self, url: Url) -> Result { + use crate::tls::Verification; + let host = url.host().ok_or(Error::InvalidDnsName)?.to_string(); let port = url.port().unwrap_or(443); - if url.scheme() == "http" { - return Err(Error::FingerprintUnsupported); - } + // `http://` fetches the relay's self-signed certificate fingerprint over + // an insecure request and pins it for this connection. It is only honored + // when no stronger verification is configured: an attacker who controls + // the plaintext fetch must not be able to weaken an explicit pin or + // re-enable verification we were told to skip. + let (url, verification) = if url.scheme() == "http" { + let mut https = url.clone(); + https.set_scheme("https").expect("https is a valid scheme"); + + if self.http_bootstrap { + let pin = fetch_fingerprint(&url).await?; + (https, Verification::Fingerprints(vec![pin])) + } else { + tracing::warn!( + "ignoring insecure http:// fingerprint bootstrap; using the configured TLS verification" + ); + (https, self.verification.clone()) + } + } else { + (url, self.verification.clone()) + }; let alpns: Vec> = match url.scheme() { "https" => vec![web_transport_quiche::ALPN.as_bytes().to_vec()], @@ -125,15 +163,26 @@ impl QuicheClient { }; let mut settings = web_transport_quiche::Settings::default(); - settings.verify_peer = !self.disable_verify; + settings.verify_peer = !matches!(verification, Verification::Disabled); settings.alpn = alpns; settings.initial_max_streams_bidi = self.max_streams; settings.initial_max_streams_uni = self.max_streams; - let builder = web_transport_quiche::ez::ClientBuilder::default() + let mut builder = web_transport_quiche::ez::ClientBuilder::default() .with_settings(settings) .with_bind(self.bind)?; + match verification { + // No hook: tokio-quiche's default config with verify_peer = false. + Verification::Disabled => {} + Verification::Fingerprints(hashes) => { + builder = builder.with_server_certificate_hashes(hashes); + } + Verification::Roots(roots) => { + builder = builder.with_root_certificates(roots); + } + } + tracing::debug!(%url, "connecting via quiche"); let mut request = web_transport_quiche::proto::ConnectRequest::new(url.clone()); @@ -177,6 +226,28 @@ impl QuicheClient { } } +/// Fetch a relay's certificate SHA-256 over an insecure `http://` request. +/// +/// This is the native equivalent of how a browser bootstraps trust for a +/// self-signed relay: GET `/certificate.sha256` and pin the returned hash. +async fn fetch_fingerprint(url: &Url) -> Result<[u8; 32]> { + let mut fp = url.clone(); + fp.set_path("/certificate.sha256"); + fp.set_query(None); + fp.set_fragment(None); + + tracing::warn!(url = %fp, "performing insecure HTTP request for certificate fingerprint"); + + let resp = reqwest::get(fp.as_str()) + .await + .map_err(Error::FetchFingerprint)? + .error_for_status() + .map_err(Error::FingerprintStatus)?; + let text = resp.text().await.map_err(Error::ReadFingerprint)?; + let bytes = hex::decode(text.trim()).map_err(Error::InvalidFingerprint)?; + bytes.try_into().map_err(|v: Vec| Error::FingerprintLength(v.len())) +} + impl Error { pub(crate) fn connect_error(&self) -> Option { match self { @@ -258,7 +329,7 @@ impl QuicheServer { .collect(); let info = Arc::new(RwLock::new(crate::tls::Info { - #[cfg(any(feature = "noq", feature = "quinn"))] + #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] certs: Vec::new(), fingerprints, })); diff --git a/rs/moq-native/src/quinn.rs b/rs/moq-native/src/quinn.rs index 8bf032823..ac747634f 100644 --- a/rs/moq-native/src/quinn.rs +++ b/rs/moq-native/src/quinn.rs @@ -117,6 +117,8 @@ type Result = std::result::Result; pub(crate) struct QuinnClient { pub quic: quinn::Endpoint, pub transport: Arc, + /// Whether an `http://` URL may bootstrap a pin (see [crate::tls::Client::allows_http_bootstrap]). + pub http_bootstrap: bool, pub versions: moq_net::Versions, } @@ -147,6 +149,7 @@ impl QuinnClient { Ok(Self { quic, transport, + http_bootstrap: config.tls.allows_http_bootstrap(), versions: config.versions(), }) } @@ -170,25 +173,34 @@ impl QuinnClient { let ip = crate::util::pick_addr(addrs, local).ok_or(Error::NoDnsEntries)?; if url.scheme() == "http" { - // Perform a HTTP request to fetch the certificate fingerprint. - let mut fingerprint = url.clone(); - fingerprint.set_path("/certificate.sha256"); - fingerprint.set_query(None); - fingerprint.set_fragment(None); - - tracing::warn!(url = %fingerprint, "performing insecure HTTP request for certificate"); - - let resp = reqwest::get(fingerprint.as_str()) - .await - .map_err(Error::FetchFingerprint)? - .error_for_status() - .map_err(Error::FingerprintStatus)?; - - let fingerprint = resp.text().await.map_err(Error::ReadFingerprint)?; - let fingerprint = hex::decode(fingerprint.trim())?; - - let verifier = FingerprintVerifier::new(config.crypto_provider().clone(), vec![fingerprint]); - config.dangerous().set_certificate_verifier(Arc::new(verifier)); + // Insecure per-connection bootstrap: only honored when no stronger + // verification is configured, so an attacker controlling the plaintext + // fetch can't weaken an explicit pin or re-enable disabled verification. + if self.http_bootstrap { + // Perform a HTTP request to fetch the certificate fingerprint. + let mut fingerprint = url.clone(); + fingerprint.set_path("/certificate.sha256"); + fingerprint.set_query(None); + fingerprint.set_fragment(None); + + tracing::warn!(url = %fingerprint, "performing insecure HTTP request for certificate"); + + let resp = reqwest::get(fingerprint.as_str()) + .await + .map_err(Error::FetchFingerprint)? + .error_for_status() + .map_err(Error::FingerprintStatus)?; + + let fingerprint = resp.text().await.map_err(Error::ReadFingerprint)?; + let fingerprint = hex::decode(fingerprint.trim())?; + + let verifier = FingerprintVerifier::new(config.crypto_provider().clone(), vec![fingerprint]); + config.dangerous().set_certificate_verifier(Arc::new(verifier)); + } else { + tracing::warn!( + "ignoring insecure http:// fingerprint bootstrap; using the configured TLS verification" + ); + } url.set_scheme("https").expect("failed to set scheme"); } diff --git a/rs/moq-native/src/tls.rs b/rs/moq-native/src/tls.rs index 92e89eff2..2ae4ee3d3 100644 --- a/rs/moq-native/src/tls.rs +++ b/rs/moq-native/src/tls.rs @@ -5,9 +5,12 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::{fs, io}; -#[cfg(any(feature = "quinn", feature = "noq"))] +#[cfg(all( + any(feature = "quinn", feature = "noq", feature = "quiche"), + any(feature = "aws-lc-rs", feature = "ring") +))] use rustls::pki_types::PrivatePkcs8KeyDer; -#[cfg(any(feature = "quinn", feature = "noq"))] +#[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] use std::sync::RwLock; /// Errors loading or generating TLS certificates and keys. @@ -36,7 +39,7 @@ pub enum Error { EmptyRoots(PathBuf), #[error( - "no trusted roots: provide --tls-root, enable --tls-system-roots, or use --tls-fingerprint / --tls-disable-verify" + "no trusted roots: provide --client-tls-root, enable --client-tls-system-roots, or use --client-tls-fingerprint / --client-tls-disable-verify" )] NoRoots, @@ -46,15 +49,17 @@ pub enum Error { #[error("invalid TLS fingerprint length: expected 32 bytes (SHA-256), got {0}")] FingerprintLength(usize), + #[error( + "--client-tls-fingerprint cannot be combined with --client-tls-root or --client-tls-system-roots: fingerprint pinning bypasses CA verification" + )] + FingerprintWithRoots, + #[error("failed to add root certificate")] AddRoot(#[source] rustls::Error), #[error("failed to configure client certificate")] ClientAuth(#[source] rustls::Error), - #[error("failed to build client certificate verifier")] - ClientVerifier(#[source] rustls::server::VerifierBuilderError), - #[error("both --client-tls-cert and --client-tls-key must be provided")] IncompleteClientAuth, @@ -75,6 +80,10 @@ pub enum Error { #[error(transparent)] Rustls(#[from] rustls::Error), + #[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] + #[error("failed to build client certificate verifier")] + ClientVerifier(#[source] rustls::server::VerifierBuilderError), + #[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] #[error(transparent)] Rcgen(#[from] rcgen::Error), @@ -111,23 +120,23 @@ pub struct Client { /// /// These roots are added on top of the system roots. By default the system /// roots are only loaded when no custom root is given, so passing a root - /// replaces them; set `--tls-system-roots` to trust both (e.g. to reach a + /// replaces them; set `--client-tls-system-roots` to trust both (e.g. to reach a /// local relay with a private CA and a remote one with a public CA). #[serde(skip_serializing_if = "Vec::is_empty")] - #[arg(id = "tls-root", long = "tls-root", env = "MOQ_CLIENT_TLS_ROOT")] + #[arg(id = "client-tls-root", long = "client-tls-root", env = "MOQ_CLIENT_TLS_ROOT")] #[serde_as(as = "serde_with::OneOrMany<_>")] pub root: Vec, /// Also trust the platform's native root certificates. /// - /// Defaults to enabled only when no `--tls-root` is given. Set it explicitly - /// to trust the system roots alongside any custom roots, or set it to false - /// to trust only the custom roots. Trusting neither (no custom root and - /// system roots disabled) is rejected, since verification could never pass. + /// Defaults to enabled only when no `--client-tls-root` is given. Set it + /// explicitly to trust the system roots alongside any custom roots, or set it + /// to false to trust only the custom roots. Trusting neither (no custom root + /// and system roots disabled) is rejected, since verification could never pass. #[serde(skip_serializing_if = "Option::is_none")] #[arg( - id = "tls-system-roots", - long = "tls-system-roots", + id = "client-tls-system-roots", + long = "client-tls-system-roots", env = "MOQ_CLIENT_TLS_SYSTEM_ROOTS", default_missing_value = "true", num_args = 0..=1, @@ -147,7 +156,11 @@ pub struct Client { /// This value can be provided multiple times to accept any of several fingerprints (e.g. /// across a certificate rotation). In config files, accepts either a single string or a TOML array. #[serde(skip_serializing_if = "Vec::is_empty")] - #[arg(id = "tls-fingerprint", long = "tls-fingerprint", env = "MOQ_CLIENT_TLS_FINGERPRINT")] + #[arg( + id = "client-tls-fingerprint", + long = "client-tls-fingerprint", + env = "MOQ_CLIENT_TLS_FINGERPRINT" + )] #[serde_as(as = "serde_with::OneOrMany<_>")] pub fingerprint: Vec, @@ -172,8 +185,8 @@ pub struct Client { /// Fine for local development and between relays, but should be used in caution in production. #[serde(skip_serializing_if = "Option::is_none")] #[arg( - id = "tls-disable-verify", - long = "tls-disable-verify", + id = "client-tls-disable-verify", + long = "client-tls-disable-verify", env = "MOQ_CLIENT_TLS_DISABLE_VERIFY", default_missing_value = "true", num_args = 0..=1, @@ -181,47 +194,200 @@ pub struct Client { value_parser = clap::value_parser!(bool), )] pub disable_verify: Option, + + /// Deprecated `--tls-*` spellings, folded into the canonical fields above with + /// a warning. Private and hidden so they stay off the public surface; not a + /// TOML field (config files use the canonical names). + #[command(flatten)] + #[serde(skip)] + deprecated: Deprecated, +} + +/// Holds the deprecated bare `--tls-*` flag spellings (renamed to `--client-tls-*`). +/// Flattened into [`Client`] so they keep parsing; folded into the canonical +/// fields by [`Client::build`] with a deprecation warning. No env (the env names +/// were never renamed) and no TOML. +#[derive(Clone, Default, Debug, clap::Args)] +struct Deprecated { + #[arg(long = "tls-root", hide = true)] + root: Vec, + + #[arg( + long = "tls-system-roots", + hide = true, + default_missing_value = "true", + num_args = 0..=1, + require_equals = true, + value_parser = clap::value_parser!(bool), + )] + system_roots: Option, + + #[arg(long = "tls-fingerprint", hide = true)] + fingerprint: Vec, + + #[arg( + long = "tls-disable-verify", + hide = true, + default_missing_value = "true", + num_args = 0..=1, + require_equals = true, + value_parser = clap::value_parser!(bool), + )] + disable_verify: Option, +} + +/// The resolved server-certificate verification policy. +/// +/// Computed once by [Client::verification] and shared by every backend (the +/// rustls-based quinn/noq via [Client::build], and quiche directly) so they +/// agree on precedence, the system-roots default, and which flag combinations +/// are valid. +#[derive(Clone)] +pub(crate) enum Verification { + /// No verification at all. Insecure; only via `--client-tls-disable-verify`. + Disabled, + + /// Pin the leaf certificate by SHA-256. The CA chain is not consulted, so + /// this is mutually exclusive with any roots. + Fingerprints(Vec<[u8; 32]>), + + /// Standard verification against these roots (system and/or custom, already + /// resolved). The two sets are additive. + Roots(Vec>), } impl Client { - /// Build a [`rustls::ClientConfig`] from this configuration. + /// Log a warning for each deprecated `--tls-*` flag in use. Called once from + /// [`Self::verification`], which every backend runs, so a deprecated flag warns once. + pub(crate) fn warn_deprecated(&self) { + if !self.deprecated.root.is_empty() { + tracing::warn!("--tls-root is deprecated; use --client-tls-root"); + } + if self.deprecated.system_roots.is_some() { + tracing::warn!("--tls-system-roots is deprecated; use --client-tls-system-roots"); + } + if !self.deprecated.fingerprint.is_empty() { + tracing::warn!("--tls-fingerprint is deprecated; use --client-tls-fingerprint"); + } + if self.deprecated.disable_verify.is_some() { + tracing::warn!("--tls-disable-verify is deprecated; use --client-tls-disable-verify"); + } + } + + /// Roots from the canonical field plus the deprecated `--tls-root` spelling. + pub(crate) fn effective_root(&self) -> Vec { + let mut root = self.root.clone(); + root.extend(self.deprecated.root.iter().cloned()); + root + } + + /// Fingerprints from the canonical field plus the deprecated `--tls-fingerprint`. + pub(crate) fn effective_fingerprint(&self) -> Vec { + let mut fp = self.fingerprint.clone(); + fp.extend(self.deprecated.fingerprint.iter().cloned()); + fp + } + + /// `system_roots`, preferring the canonical flag over the deprecated alias. + pub(crate) fn effective_system_roots(&self) -> Option { + self.system_roots.or(self.deprecated.system_roots) + } + + /// `disable_verify`, preferring the canonical flag over the deprecated alias. + pub(crate) fn effective_disable_verify(&self) -> Option { + self.disable_verify.or(self.deprecated.disable_verify) + } + + /// Resolve the verification policy from the configured flags. /// - /// Trusts the configured roots plus the platform's native roots (the latter - /// gated by `system_roots`), optionally attaches a client identity for mTLS, - /// and swaps in fingerprint pinning or disabled verification when requested. - pub fn build(&self) -> Result { - let provider = crypto::provider(); + /// Precedence and rules (shared by all backends): + /// - `--client-tls-disable-verify` wins and disables verification. + /// - `--client-tls-fingerprint` pins the leaf and bypasses the CA chain; combining + /// it with `--client-tls-root` or `--client-tls-system-roots` is rejected rather than + /// silently ignoring one of them. + /// - Otherwise, verify against the system roots (default) plus any custom + /// roots. The system roots are dropped once a custom root is given unless + /// `--client-tls-system-roots` re-enables them. + pub(crate) fn verification(&self) -> Result { + self.warn_deprecated(); + + if self.effective_disable_verify().unwrap_or_default() { + return Ok(Verification::Disabled); + } + let fingerprints = self.fingerprints()?; + if !fingerprints.is_empty() { + if !self.effective_root().is_empty() || self.effective_system_roots() == Some(true) { + return Err(Error::FingerprintWithRoots); + } + return Ok(Verification::Fingerprints(fingerprints)); + } + + let root = self.effective_root(); // Default to system roots only when no custom root is given, so passing a // root replaces them unless the system roots are explicitly re-enabled. - let system_roots = self.system_roots.unwrap_or(self.root.is_empty()); - - // fingerprint pinning and disable_verify swap in their own verifier below, - // so an empty root store is fine in those cases. Otherwise WebPKI needs at - // least one trusted root to ever succeed, so fail fast instead of producing - // confusing handshake errors later. - let custom_verifier = self.disable_verify.unwrap_or_default() || !self.fingerprint.is_empty(); - if !system_roots && self.root.is_empty() && !custom_verifier { - return Err(Error::NoRoots); - } + let system_roots = self.effective_system_roots().unwrap_or(root.is_empty()); - let mut roots = rustls::RootCertStore::empty(); + let mut roots = Vec::new(); if system_roots { let native = rustls_native_certs::load_native_certs(); for err in native.errors { tracing::warn!(%err, "failed to load root cert"); } - for cert in native.certs { - roots.add(cert).map_err(Error::AddRoot)?; - } + roots.extend(native.certs); } - for root in &self.root { + for root in &root { let certs = read_certs(root)?; if certs.is_empty() { return Err(Error::EmptyRoots(root.clone())); } + roots.extend(certs); + } + + // WebPKI needs at least one trusted root to ever succeed, so fail fast + // instead of producing confusing handshake errors later. + if roots.is_empty() { + return Err(Error::NoRoots); + } + + Ok(Verification::Roots(roots)) + } + + /// Whether an insecure `http://` certificate-fingerprint bootstrap may be + /// honored for a connection. + /// + /// Only when no stronger verification is configured: an explicit + /// `--client-tls-fingerprint` must never be weakened by an attacker-controlled + /// plaintext fetch, and there is nothing to bootstrap when verification is + /// disabled. With CA roots (the default), `http://` is the deliberate + /// per-connection way to pin a self-signed relay, so it is allowed. + pub(crate) fn allows_http_bootstrap(&self) -> bool { + self.effective_fingerprint().is_empty() && !self.effective_disable_verify().unwrap_or_default() + } + + /// Parse the configured fingerprints into fixed-size SHA-256 digests. + fn fingerprints(&self) -> Result> { + self.effective_fingerprint() + .iter() + .map(|fp| { + let bytes = hex::decode(fp.trim()).map_err(Error::Fingerprint)?; + bytes.try_into().map_err(|v: Vec| Error::FingerprintLength(v.len())) + }) + .collect() + } + + /// Build a [`rustls::ClientConfig`] from this configuration. + /// + /// Resolves the verification policy, optionally attaches a client identity + /// for mTLS, and installs the matching verifier. + pub fn build(&self) -> Result { + let provider = crypto::provider(); + let verification = self.verification()?; + + let mut roots = rustls::RootCertStore::empty(); + if let Verification::Roots(certs) = &verification { for cert in certs { - roots.add(cert).map_err(Error::AddRoot)?; + roots.add(cert.clone()).map_err(Error::AddRoot)?; } } @@ -248,25 +414,21 @@ impl Client { _ => return Err(Error::IncompleteClientAuth), }; - if self.disable_verify.unwrap_or_default() { - tracing::warn!("TLS server certificate verification is disabled; A man-in-the-middle attack is possible."); - let noop = NoCertificateVerification(provider); - tls.dangerous().set_certificate_verifier(Arc::new(noop)); - } else if !self.fingerprint.is_empty() { - let fingerprints = self - .fingerprint - .iter() - .map(|fp| { - let bytes = hex::decode(fp.trim()).map_err(Error::Fingerprint)?; - match bytes.len() { - 32 => Ok(bytes), - len => Err(Error::FingerprintLength(len)), - } - }) - .collect::>>()?; - - let verifier = FingerprintVerifier::new(provider, fingerprints); - tls.dangerous().set_certificate_verifier(Arc::new(verifier)); + match verification { + Verification::Disabled => { + tracing::warn!( + "TLS server certificate verification is disabled; A man-in-the-middle attack is possible." + ); + tls.dangerous() + .set_certificate_verifier(Arc::new(NoCertificateVerification(provider))); + } + Verification::Fingerprints(fingerprints) => { + let fingerprints = fingerprints.into_iter().map(|fp| fp.to_vec()).collect(); + let verifier = FingerprintVerifier::new(provider, fingerprints); + tls.dangerous().set_certificate_verifier(Arc::new(verifier)); + } + // Roots are already in the store above; use the default WebPKI verifier. + Verification::Roots(_) => {} } Ok(tls) @@ -318,7 +480,10 @@ pub struct Server { /// and can be used by the application to grant elevated access. Clients that /// do not present a certificate are unaffected. /// - /// Only supported by the Quinn and noq backends. + /// Client certificate reporting is only supported by the Quinn and noq QUIC + /// backends. Plain-TLS listeners built via [`Self::server_config`] also use + /// these roots for optional mTLS when the feature set includes quinn, noq, or + /// quiche. #[arg( long = "server-tls-root", id = "server-tls-root", @@ -346,19 +511,48 @@ impl Server { Ok(roots) } - /// Build a [`rustls::ServerConfig`] for a TCP/TLS listener (e.g. an HTTP - /// server fronting the QUIC endpoint), reusing the QUIC backend's certificate - /// handling: on-disk `cert`/`key` pairs, `generate` self-signed certs, and - /// optional mTLS `root` client CAs. + /// Build a [`rustls::ServerConfig`] for a plain-TLS (non-QUIC) server, e.g. an + /// RTMPS or HTTPS listener fronting the QUIC endpoint, reusing the QUIC + /// backend's certificate handling: on-disk `cert`/`key` pairs, `generate` + /// self-signed certs, and optional mTLS `root` client CAs. /// - /// `alpn` sets the advertised ALPN protocols, e.g. - /// `vec![b"h2".to_vec(), b"http/1.1".to_vec()]`. - #[cfg(any(feature = "noq", feature = "quinn"))] + /// `alpn` sets the advertised ALPN protocols (e.g. + /// `vec![b"h2".to_vec(), b"http/1.1".to_vec()]`); pass an empty list for a + /// protocol like RTMPS that doesn't use ALPN. + #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] pub fn server_config(&self, alpn: Vec>) -> Result> { server_config(self, alpn) } } +/// Build a [`rustls::ServerConfig`] from a [`Server`] for a plain-TLS listener. +#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] +fn server_config(config: &Server, alpn: Vec>) -> Result> { + let provider = crypto::provider(); + + let certs = ServeCerts::new(provider.clone()); + certs.load_certs(config)?; + let certs = Arc::new(certs); + + // TCP can negotiate TLS 1.2 as well as 1.3, unlike QUIC which is 1.3-only. + let builder = + rustls::ServerConfig::builder_with_provider(provider.clone()).with_safe_default_protocol_versions()?; + + let mut tls = if config.root.is_empty() { + builder.with_no_client_auth().with_cert_resolver(certs) + } else { + let roots = config.load_roots()?; + let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider) + .allow_unauthenticated() + .build() + .map_err(Error::ClientVerifier)?; + builder.with_client_cert_verifier(verifier).with_cert_resolver(certs) + }; + + tls.alpn_protocols = alpn; + Ok(Arc::new(tls)) +} + /// A peer's validated client-certificate chain from the mTLS handshake. /// /// Returned by [`crate::Request::peer_identity`] when the peer presented a @@ -403,7 +597,7 @@ impl PeerIdentity { /// TLS certificate information including fingerprints. #[derive(Debug)] pub struct Info { - #[cfg(any(feature = "noq", feature = "quinn"))] + #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] pub(crate) certs: Vec>, pub fingerprints: Vec, } @@ -625,18 +819,42 @@ mod tests { }; assert!(config.build().is_ok()); } + + #[test] + fn build_rejects_fingerprint_with_roots() { + let cert = self_signed(); + let fingerprint = hex::encode(crypto::sha256(&crypto::provider(), cert.as_ref())); + + // Fingerprint pinning bypasses the CA chain, so combining it with roots + // is rejected rather than silently ignoring one of them. + let with_system = Client { + fingerprint: vec![fingerprint.clone()], + system_roots: Some(true), + ..Default::default() + }; + assert!(matches!(with_system.build(), Err(Error::FingerprintWithRoots))); + + // The conflict is detected before any root file is read, so the path + // need not exist. + let with_custom = Client { + fingerprint: vec![fingerprint], + root: vec![PathBuf::from("/does-not-exist.pem")], + ..Default::default() + }; + assert!(matches!(with_custom.build(), Err(Error::FingerprintWithRoots))); + } } // ── ServeCerts ────────────────────────────────────────────────────── -#[cfg(any(feature = "quinn", feature = "noq"))] +#[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] #[derive(Debug)] pub(crate) struct ServeCerts { pub info: Arc>, provider: crypto::Provider, } -#[cfg(any(feature = "quinn", feature = "noq"))] +#[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] impl ServeCerts { pub fn new(provider: crypto::Provider) -> Self { Self { @@ -761,7 +979,7 @@ impl ServeCerts { } } -#[cfg(any(feature = "quinn", feature = "noq"))] +#[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] impl rustls::server::ResolvesServerCert for ServeCerts { fn resolve(&self, client_hello: rustls::server::ClientHello<'_>) -> Option> { if let Some(cert) = self.best_certificate(&client_hello) { @@ -781,37 +999,6 @@ impl rustls::server::ResolvesServerCert for ServeCerts { } } -// ── server_config ─────────────────────────────────────────────────── - -/// Build a rustls server config for a TCP/TLS listener, sharing the QUIC -/// backend's cert loading (on-disk pairs, generated self-signed, mTLS roots). -#[cfg(any(feature = "noq", feature = "quinn"))] -pub(crate) fn server_config(config: &Server, alpn: Vec>) -> Result> { - let provider = crate::crypto::provider(); - - let certs = ServeCerts::new(provider.clone()); - certs.load_certs(config)?; - let certs = Arc::new(certs); - - // TCP can negotiate TLS 1.2 as well as 1.3, unlike QUIC which is 1.3-only. - let builder = - rustls::ServerConfig::builder_with_provider(provider.clone()).with_safe_default_protocol_versions()?; - - let mut tls = if config.root.is_empty() { - builder.with_no_client_auth().with_cert_resolver(certs) - } else { - let roots = config.load_roots()?; - let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider) - .allow_unauthenticated() - .build() - .map_err(Error::ClientVerifier)?; - builder.with_client_cert_verifier(verifier).with_cert_resolver(certs) - }; - - tls.alpn_protocols = alpn; - Ok(Arc::new(tls)) -} - // ── reload_certs ──────────────────────────────────────────────────── /// Watch the on-disk cert/key files and reload them whenever they change. diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 08244a3fb..3dfcedeca 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -56,7 +56,7 @@ thiserror = "2" tokio = { workspace = true, features = ["full"] } tokio-rustls = { version = "0.26", default-features = false } toml = "1.1" -tower-http = { version = "0.6", features = ["cors"] } +tower-http = { version = "0.7", features = ["cors"] } tower-service = "0.3" tracing = "0.1" url = { version = "2", features = ["serde"] } diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index 61cdd84f5..04850e76f 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -206,6 +206,50 @@ preferred_v6 = "[2001:db8::1]:443" ); } + /// Serializes tests that touch `MOQ_WEB_HTTPS_*`. Same rationale as + /// `STATS_ENV_LOCK`. + static WEB_HTTPS_ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn cli_does_not_clobber_toml_web_https_cert_arrays() { + let _guard = WEB_HTTPS_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // SAFETY: WEB_HTTPS_ENV_LOCK ensures no other test in this binary + // touches these env vars concurrently. + unsafe { + std::env::remove_var("MOQ_WEB_HTTPS_CERT"); + std::env::remove_var("MOQ_WEB_HTTPS_KEY"); + } + + let toml = r#" +[web.https] +listen = "127.0.0.1:4443" +cert = ["cdn.pem", "moq-pro.pem"] +key = ["cdn.key", "moq-pro.key"] +"#; + let dir = std::env::temp_dir().join("moq-relay-config-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("web-https-certs-toml-wins.toml"); + std::fs::write(&path, toml).unwrap(); + + let args = vec![std::ffi::OsString::from("moq-relay"), std::ffi::OsString::from(&path)]; + let config = Config::parse_and_merge(args).expect("config load"); + + assert_eq!( + config.web.https.cert, + vec![ + std::path::PathBuf::from("cdn.pem"), + std::path::PathBuf::from("moq-pro.pem") + ] + ); + assert_eq!( + config.web.https.key, + vec![ + std::path::PathBuf::from("cdn.key"), + std::path::PathBuf::from("moq-pro.key") + ] + ); + } + /// Explicit CLI flag must still override TOML. Belt-and-suspenders for the /// fix above: making `enabled: Option` shouldn't break the override /// path. @@ -263,7 +307,7 @@ auth_api = "https://api.moq.dev/cluster/auth" } /// Same clap+TOML clobber guard for `client.system_roots`. It's typed as - /// `Option` so an absent `--tls-system-roots` CLI flag must not wipe a + /// `Option` so an absent `--client-tls-system-roots` CLI flag must not wipe a /// TOML-configured value during the `update_from` re-parse. A bare `bool` /// would reset it to `false`, silently dropping the system roots for a /// cluster client that opted into trusting both system and custom roots. diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index d6c425184..bd4fa4d97 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -61,7 +61,7 @@ pub struct HttpConfig { pub listen: Option, } -/// HTTPS listener configuration with TLS certificate and key. +/// HTTPS listener configuration with TLS certificates and keys. #[serde_with::serde_as] #[derive(clap::Args, Clone, Debug, Default, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields, default)] @@ -71,13 +71,32 @@ pub struct HttpsConfig { #[arg(long = "web-https-listen", id = "web-https-listen", env = "MOQ_WEB_HTTPS_LISTEN", requires_all = ["web-https-cert", "web-https-key"])] pub listen: Option, - /// Load the given certificate from disk. - #[arg(long = "web-https-cert", id = "web-https-cert", env = "MOQ_WEB_HTTPS_CERT")] - pub cert: Option, + /// Load the given certificate chain files from disk. + /// + /// In config files, accepts either a single string or a TOML array. + #[arg( + long = "web-https-cert", + id = "web-https-cert", + value_delimiter = ',', + env = "MOQ_WEB_HTTPS_CERT" + )] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde_as(as = "serde_with::OneOrMany<_>")] + pub cert: Vec, - /// Load the given key from disk. - #[arg(long = "web-https-key", id = "web-https-key", env = "MOQ_WEB_HTTPS_KEY")] - pub key: Option, + /// Load the given private key files from disk. + /// + /// Each key is paired with the certificate chain at the same index. + /// In config files, accepts either a single string or a TOML array. + #[arg( + long = "web-https-key", + id = "web-https-key", + value_delimiter = ',', + env = "MOQ_WEB_HTTPS_KEY" + )] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde_as(as = "serde_with::OneOrMany<_>")] + pub key: Vec, /// PEM file(s) of root CAs for validating optional client certificates (mTLS). /// @@ -196,12 +215,12 @@ impl Web { }; let https = if let Some(listen) = config.https.listen { - let cert = config.https.cert.expect("missing https.cert"); - let key = config.https.key.expect("missing https.key"); + let cert = config.https.cert.clone(); + let key = config.https.key.clone(); let root = config.https.root.clone(); - let tls = build_https_config(&cert, &key, &root).await?; - let rustls_config = RustlsConfig::from_config(Arc::new(tls)); + let rustls = build_https_config(&cert, &key, &root)?; + let rustls_config = RustlsConfig::from_config(rustls); tokio::spawn(reload_https_config(rustls_config.clone(), cert, key, root)); @@ -239,66 +258,29 @@ impl Web { /// Build a [`rustls::ServerConfig`] for the HTTPS listener. /// -/// When `root` is non-empty, installs a [`WebPkiClientVerifier`] with -/// `.allow_unauthenticated()` so JWT-only callers still complete the -/// handshake without presenting a cert. When empty, falls back to -/// [`with_no_client_auth`]. ALPN is set to `h2`, `http/1.1` to match -/// axum-server's defaults. -/// /// TLS version is left at the rustls default (1.2 + 1.3) so older clients /// can still hit the HTTPS API; the QUIC server separately forces 1.3. -async fn build_https_config( - cert: &std::path::Path, - key: &std::path::Path, +fn build_https_config( + cert: &[PathBuf], + key: &[PathBuf], root: &[PathBuf], -) -> anyhow::Result { - use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; - use rustls::server::WebPkiClientVerifier; - - let cert_chain: Vec> = CertificateDer::pem_file_iter(cert) - .context("failed to open https cert")? - .collect::>() - .context("failed to parse https cert")?; - let key_der = PrivateKeyDer::from_pem_file(key).context("failed to parse https key")?; - - let provider = rustls::crypto::CryptoProvider::get_default() - .cloned() - .expect("no default crypto provider installed"); - - let builder = - rustls::ServerConfig::builder_with_provider(provider.clone()).with_safe_default_protocol_versions()?; - - let mut config = if root.is_empty() { - builder - .with_no_client_auth() - .with_single_cert(cert_chain, key_der) - .context("invalid https cert/key pair")? - } else { - // Build the CA root store inline; `moq_native::tls::Server` is - // `non_exhaustive`, so we can't construct one to call its `load_roots`. - let mut root_store = rustls::RootCertStore::empty(); - for path in root { - let mut found = false; - for cert in CertificateDer::pem_file_iter(path).context("failed to open mTLS client CA")? { - let cert = cert.context("failed to parse mTLS client CA PEM")?; - root_store.add(cert).context("failed to add mTLS client CA")?; - found = true; - } - anyhow::ensure!(found, "no certificates found in mTLS client CA: {}", path.display()); - } - let verifier = WebPkiClientVerifier::builder_with_provider(Arc::new(root_store), provider) - .allow_unauthenticated() - .build() - .context("failed to build https client cert verifier")?; - - builder - .with_client_cert_verifier(verifier) - .with_single_cert(cert_chain, key_der) - .context("invalid https cert/key pair")? - }; - - config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - Ok(config) +) -> anyhow::Result> { + anyhow::ensure!( + !cert.is_empty(), + "web.https.cert must include at least one certificate when web.https.listen is configured" + ); + anyhow::ensure!( + cert.len() == key.len(), + "web.https.cert and web.https.key must have the same number of entries" + ); + + let mut tls = moq_native::tls::Server::default(); + tls.cert = cert.to_vec(); + tls.key = key.to_vec(); + tls.root = root.to_vec(); + + tls.server_config(vec![b"h2".to_vec(), b"http/1.1".to_vec()]) + .context("failed to build https TLS config") } /// Reload the HTTPS cert/key/root whenever they change on disk. @@ -306,9 +288,11 @@ async fn build_https_config( /// `RustlsConfig::reload_from_pem_file` would rebuild with `with_no_client_auth` /// (silently stripping mTLS when configured), so we always rebuild via the full /// [`build_https_config`] path. -async fn reload_https_config(config: RustlsConfig, cert: PathBuf, key: PathBuf, root: Vec) { - let paths: Vec = std::iter::once(cert.clone()) - .chain(std::iter::once(key.clone())) +async fn reload_https_config(config: RustlsConfig, cert: Vec, key: Vec, root: Vec) { + let paths: Vec = cert + .iter() + .cloned() + .chain(key.iter().cloned()) .chain(root.iter().cloned()) .collect(); @@ -324,8 +308,8 @@ async fn reload_https_config(config: RustlsConfig, cert: PathBuf, key: PathBuf, watcher.changed().await; tracing::info!("reloading web certificate"); - match build_https_config(&cert, &key, &root).await { - Ok(new) => config.reload_from_config(Arc::new(new)), + match build_https_config(&cert, &key, &root) { + Ok(new) => config.reload_from_config(new), Err(err) => tracing::warn!(%err, "failed to reload web certificate"), } } @@ -730,9 +714,13 @@ mod tests { use std::io::Write; use tempfile::TempDir; + fn make_certs(dir: &TempDir) -> (PathBuf, PathBuf, PathBuf) { + make_named_certs(dir, "server", "localhost") + } + /// Generate a CA + server cert/key on disk and return the temp paths. /// Modeled after `auth.rs::mtls_fixture`. - fn make_certs(dir: &TempDir) -> (PathBuf, PathBuf, PathBuf) { + fn make_named_certs(dir: &TempDir, name: &str, hostname: &str) -> (PathBuf, PathBuf, PathBuf) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let ca_kp = KeyPair::generate().unwrap(); @@ -743,15 +731,15 @@ mod tests { let ca_issuer = rcgen::Issuer::from_params(&ca_params, &ca_kp); let server_kp = KeyPair::generate().unwrap(); - let mut server_params = CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + let mut server_params = CertificateParams::new(vec![hostname.to_string()]).unwrap(); server_params .distinguished_name - .push(rcgen::DnType::CommonName, "test-server"); + .push(rcgen::DnType::CommonName, format!("test-{name}")); let server_cert = server_params.signed_by(&server_kp, &ca_issuer).unwrap(); - let ca_path = dir.path().join("ca.pem"); - let cert_path = dir.path().join("server.cert.pem"); - let key_path = dir.path().join("server.key.pem"); + let ca_path = dir.path().join(format!("{name}.ca.pem")); + let cert_path = dir.path().join(format!("{name}.cert.pem")); + let key_path = dir.path().join(format!("{name}.key.pem")); std::fs::write(&ca_path, ca_cert.pem()).unwrap(); std::fs::write(&cert_path, server_cert.pem()).unwrap(); std::fs::write(&key_path, server_kp.serialize_pem()).unwrap(); @@ -764,9 +752,8 @@ mod tests { let dir = TempDir::new().unwrap(); let (ca_path, cert_path, key_path) = make_certs(&dir); - let config = build_https_config(&cert_path, &key_path, &[ca_path]) - .await - .expect("build_https_config should succeed"); + let config = + build_https_config(&[cert_path], &[key_path], &[ca_path]).expect("build_https_config should succeed"); // ALPN must include h2 + http/1.1; otherwise reqwest's h2 attempt // would silently downgrade or fail. Mirrors axum_server's default. @@ -784,23 +771,49 @@ mod tests { // Empty root is the JWT-only path; should still produce a valid // config with ALPN set so axum-server's hyper layer can negotiate h2. - let config = build_https_config(&cert_path, &key_path, &[]) - .await - .expect("no-CA path should still build a usable config"); + let config = + build_https_config(&[cert_path], &[key_path], &[]).expect("no-CA path should still build a usable config"); assert_eq!(config.alpn_protocols, vec![b"h2".to_vec(), b"http/1.1".to_vec()],); } + #[tokio::test] + async fn build_https_config_accepts_multiple_cert_pairs() { + let dir = TempDir::new().unwrap(); + let (_ca_a, cert_a, key_a) = make_named_certs(&dir, "cdn", "cdn.moq.dev"); + let (_ca_b, cert_b, key_b) = make_named_certs(&dir, "pro", "moq.pro"); + + let config = build_https_config(&[cert_a, cert_b], &[key_a, key_b], &[]) + .expect("multiple HTTPS cert/key pairs should build"); + + assert_eq!(config.alpn_protocols, vec![b"h2".to_vec(), b"http/1.1".to_vec()]); + } + #[tokio::test] async fn build_https_config_rejects_missing_ca() { let dir = TempDir::new().unwrap(); let (_ca_path, cert_path, key_path) = make_certs(&dir); let bogus = dir.path().join("does-not-exist.pem"); - let res = build_https_config(&cert_path, &key_path, &[bogus]).await; + let res = build_https_config(&[cert_path], &[key_path], &[bogus]); assert!(res.is_err(), "missing CA file should be a hard error"); } + #[tokio::test] + async fn build_https_config_rejects_empty_cert_list() { + let res = build_https_config(&[], &[], &[]); + assert!(res.is_err(), "HTTPS must require at least one cert/key pair"); + } + + #[tokio::test] + async fn build_https_config_rejects_mismatched_cert_key_lists() { + let dir = TempDir::new().unwrap(); + let (_ca_path, cert_path, _key_path) = make_certs(&dir); + + let res = build_https_config(&[cert_path], &[], &[]); + assert!(res.is_err(), "HTTPS cert/key lists must be paired"); + } + #[tokio::test] async fn build_https_config_rejects_empty_pem() { let dir = TempDir::new().unwrap(); @@ -810,7 +823,7 @@ mod tests { let mut f = std::fs::File::create(&empty).unwrap(); writeln!(f, "# no certs here").unwrap(); - let res = build_https_config(&cert_path, &key_path, &[empty]).await; + let res = build_https_config(&[cert_path], &[key_path], &[empty]); assert!( res.is_err(), "empty PEM must be rejected to avoid a silently disabled verifier" diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index b5886d085..033ac9b18 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -29,10 +29,7 @@ fn newest_lite_version() -> moq_net::Version { .expect("parse newest lite ALPN as a Version") } -/// The shared bootstrap: stand up a relay listening on `127.0.0.1:` -/// with fully public auth, and return the port plus an abort handle for the -/// spawned web server. -async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { +async fn build_web(port: u16, ws: bool) -> Web { // Crypto provider is process-global; reinstalls after the first one are // no-ops, but the test binary may run before any other moq code does. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); @@ -56,6 +53,14 @@ async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { server_config.tls.generate = vec!["localhost".into()]; let server = server_config.init().expect("server init"); + let mut web_config = WebConfig::default(); + web_config.ws = ws; + web_config.http.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); + + Web::new(auth, cluster, server.tls_info(), web_config) +} + +fn free_tcp_port() -> u16 { // Pick a free port for HTTP, then immediately drop the probe listener // so axum_server can bind it. There's a tiny race window where the // kernel could hand the same port to another process, but on localhost @@ -63,18 +68,10 @@ async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); let port = probe.local_addr().expect("local addr").port(); drop(probe); + port +} - let mut web_config = WebConfig::default(); - web_config.ws = true; - web_config.http.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); - - let web = Web::new(auth, cluster, server.tls_info(), web_config); - - let handle = tokio::spawn(async move { - // `Web::run` only returns on error; in tests we abort it at teardown. - let _ = web.run().await; - }); - +async fn wait_for_http(port: u16, server_result: &mut tokio::sync::oneshot::Receiver>) { // Wait for axum_server to bind. A short poll is more reliable than a // fixed sleep when CI is slow. let deadline = std::time::Instant::now() + Duration::from_secs(5); @@ -82,11 +79,35 @@ async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { break; } + match server_result.try_recv() { + Ok(Ok(())) => panic!("relay web server exited before listening"), + Ok(Err(err)) => panic!("relay web server failed before listening: {err:#}"), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {} + Err(tokio::sync::oneshot::error::TryRecvError::Closed) => { + panic!("relay web server task ended before listening") + } + } if std::time::Instant::now() >= deadline { panic!("relay http listener never became ready on port {port}"); } tokio::time::sleep(Duration::from_millis(25)).await; } +} + +/// The shared bootstrap: stand up a relay listening on `127.0.0.1:` +/// with fully public auth, and return the port plus an abort handle for the +/// spawned web server. +async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { + let port = free_tcp_port(); + let web = build_web(port, true).await; + + let (server_result_tx, mut server_result_rx) = tokio::sync::oneshot::channel(); + let handle = tokio::spawn(async move { + // `Web::run` only returns on error; in tests we abort it at teardown. + let _ = server_result_tx.send(web.run().await); + }); + + wait_for_http(port, &mut server_result_rx).await; (port, handle) } @@ -180,6 +201,33 @@ async fn relay_websocket_round_trip_uses_newest_version() { web_handle.abort(); } +#[tokio::test] +async fn relay_web_serves_merged_routes() { + tokio::time::pause(); + let port = free_tcp_port(); + let web = build_web(port, false).await; + let app = web + .routes() + .route("/embedded", axum::routing::get(|| async { "embedded\n" })); + + let (server_result_tx, mut server_result_rx) = tokio::sync::oneshot::channel(); + let handle = tokio::spawn(async move { + let _ = server_result_tx.send(web.serve(app).await); + }); + + wait_for_http(port, &mut server_result_rx).await; + + let body = reqwest::get(format!("http://127.0.0.1:{port}/embedded")) + .await + .expect("fetch embedded route") + .text() + .await + .expect("read embedded response"); + assert_eq!(body, "embedded\n"); + + handle.abort(); +} + /// A client that dials a bare `host:port` with no path must still get a /// WebSocket upgrade at the root, not the landing page. The empty path is the /// root auth scope (same as the internal listener). Regression for the diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index d35fb619c..d79fa3136 100644 --- a/rs/moq-rtc/Cargo.toml +++ b/rs/moq-rtc/Cargo.toml @@ -55,7 +55,7 @@ rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false, str0m = "0.19" thiserror = "2" tokio = { workspace = true, features = ["full"] } -tower-http = { version = "0.6", features = ["cors"], optional = true } +tower-http = { version = "0.7", features = ["cors"], optional = true } tracing = "0.1" url = "2" uuid = { version = "1", features = ["v4"] } diff --git a/rs/moq-rtmp/Cargo.toml b/rs/moq-rtmp/Cargo.toml index 108f8e1b4..dace71ae3 100644 --- a/rs/moq-rtmp/Cargo.toml +++ b/rs/moq-rtmp/Cargo.toml @@ -64,7 +64,7 @@ tokio = { workspace = true, features = ["full"] } # workspace shares one tokio-rustls; the crypto provider comes from the supplied # `rustls::ServerConfig`, so no default crypto feature is needed here. tokio-rustls = { version = "0.26", default-features = false, optional = true } -tower-http = { version = "0.6", features = ["cors", "fs"], optional = true } +tower-http = { version = "0.7", features = ["cors", "fs"], optional = true } tracing = "0.1" url = { version = "2", optional = true } diff --git a/rs/moq-rtmp/bin/moq-rtmp.rs b/rs/moq-rtmp/bin/moq-rtmp.rs index 06b74151d..65881ed84 100644 --- a/rs/moq-rtmp/bin/moq-rtmp.rs +++ b/rs/moq-rtmp/bin/moq-rtmp.rs @@ -85,19 +85,27 @@ struct RtmpArgs { /// addition to plaintext RTMP. Requires a certificate via `--rtmps-tls-cert` /// /`--rtmps-tls-key` or a self-signed `--rtmps-tls-generate`. RTMPS has no /// well-known port; common choices are 443 or a custom one. + /// + /// The bundled RTMPS terminator reuses moq-native's certificate loader, which + /// is only built with the `noq` or `quinn` backend; these flags are absent in + /// other builds. + #[cfg(any(feature = "noq", feature = "quinn"))] #[arg(long = "rtmps-listen", env = "MOQ_RTMPS_LISTEN")] rtmps_listen: Option, /// PEM certificate chain for RTMPS. + #[cfg(any(feature = "noq", feature = "quinn"))] #[arg(long = "rtmps-tls-cert", env = "MOQ_RTMPS_TLS_CERT")] rtmps_cert: Option, /// PEM private key for RTMPS. + #[cfg(any(feature = "noq", feature = "quinn"))] #[arg(long = "rtmps-tls-key", env = "MOQ_RTMPS_TLS_KEY")] rtmps_key: Option, /// Or generate a self-signed RTMPS certificate for these hostnames (testing /// only; clients must disable verification or pin the fingerprint). + #[cfg(any(feature = "noq", feature = "quinn"))] #[arg(long = "rtmps-tls-generate", env = "MOQ_RTMPS_TLS_GENERATE", value_delimiter = ',')] rtmps_generate: Vec, } diff --git a/rs/moq-rtmp/src/server.rs b/rs/moq-rtmp/src/server.rs index fd84c3f55..c7f2f0e7f 100644 --- a/rs/moq-rtmp/src/server.rs +++ b/rs/moq-rtmp/src/server.rs @@ -365,13 +365,31 @@ impl Publish { /// token). This future resolves when the connection ends, so callers usually /// run it on its own task. pub async fn accept(mut self, origin: &OriginProducer, path: &str) -> Result<()> { + // Reserve the broadcast path before telling the client the publish succeeded: + // if the origin refuses `path`, reject cleanly instead of accepting and then + // dropping the connection a moment later. + let mut publisher = match Publisher::new(origin, path) { + Ok(publisher) => publisher, + Err(err) => { + tracing::warn!(peer = %self.peer, %path, %err, "rejecting RTMP publish: broadcast unavailable"); + let results = self + .session + .reject_request(self.request_id, "NetStream.Publish.Denied", "broadcast unavailable") + .map_err(|e| anyhow::anyhow!("rtmp reject publish: {e:?}"))?; + for result in self.work.drain(..).chain(results) { + if let ServerSessionResult::OutboundResponse(packet) = result { + self.stream.write_all(&packet.bytes).await?; + } + } + return Ok(()); + } + }; + let results = self .session .accept_request(self.request_id) .map_err(|e| anyhow::anyhow!("rtmp accept publish: {e:?}"))?; self.work.extend(results); - - let mut publisher = Publisher::new(origin, path)?; tracing::info!(peer = %self.peer, %path, "rtmp publish accepted"); let result = pump( diff --git a/rs/moq-srt/Cargo.toml b/rs/moq-srt/Cargo.toml index 4aa43c93e..f55d6367d 100644 --- a/rs/moq-srt/Cargo.toml +++ b/rs/moq-srt/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moq-srt" -description = "SRT contribution ingest gateway for Media over QUIC" +description = "Bidirectional SRT gateway for Media over QUIC" authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" @@ -54,7 +54,7 @@ rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"], srt-tokio = "0.4" thiserror = "2" tokio = { workspace = true, features = ["full"] } -tower-http = { version = "0.6", features = ["cors", "fs"], optional = true } +tower-http = { version = "0.7", features = ["cors", "fs"], optional = true } tracing = "0.1" url = { version = "2", optional = true } diff --git a/rs/moq-srt/bin/moq-srt.rs b/rs/moq-srt/bin/moq-srt.rs index ed9e3d2a2..dfa5feede 100644 --- a/rs/moq-srt/bin/moq-srt.rs +++ b/rs/moq-srt/bin/moq-srt.rs @@ -114,9 +114,10 @@ async fn main() -> anyhow::Result<()> { /// Run a local QUIC/WebTransport server and ingest SRT directly into it. async fn run_serve(config: moq_native::ServerConfig, dir: Option, srt: moq_srt::Config) -> anyhow::Result<()> { - let web_bind = config.bind.clone().unwrap_or_else(|| "[::]:443".to_string()); - let server = config.init().context("init moq server")?; + // Derive the HTTP sidecar bind from the actual listener, so a port-0 or + // hostname-resolved server still serves /certificate.sha256 on the real socket. + let web_bind = server.local_addr().context("server local addr")?.to_string(); let web_tls = server.tls_info(); // SRT publishes broadcasts into this origin; the server serves them out. diff --git a/rs/moq-srt/src/server.rs b/rs/moq-srt/src/server.rs index 48b388aaf..c8ee8fca6 100644 --- a/rs/moq-srt/src/server.rs +++ b/rs/moq-srt/src/server.rs @@ -367,7 +367,12 @@ struct Paced { /// the receiver owns the jitter buffer (the SRT latency parameter), so the sender /// adds no lookahead of its own (a deliberate lead would just be `now + lead` here). fn pace(anchor: Instant, base: moq_net::Timestamp, ts: moq_net::Timestamp, now: Instant) -> Paced { - let send_at = anchor + Duration::from(ts).saturating_sub(Duration::from(base)); + let send_at = match ts.checked_sub(base) { + Ok(offset) => anchor + Duration::from(offset), + // A reordered B-frame can carry a PTS before the anchor: pace it at that earlier + // instant instead of collapsing it onto the anchor. + Err(_) => anchor.checked_sub(Duration::from(base - ts)).unwrap_or(anchor), + }; if send_at > now { // Media outran wall-clock: re-anchor so this newest frame is the live edge. Paced { @@ -464,6 +469,21 @@ mod tests { jittered.anchor, edge.anchor, "no re-anchor when media is behind wall-clock" ); + + // A reordered B-frame can carry a PTS before the re-anchored live edge. Keep that + // earlier media instant instead of flattening it onto the anchor. + let reordered = pace( + edge.anchor, + edge.base, + ms(4_099), + edge.anchor + Duration::from_millis(20), + ); + assert_eq!( + reordered.send_at, + edge.anchor - Duration::from_millis(33), + "a reordered frame can pace before the anchor" + ); + assert_eq!(reordered.anchor, edge.anchor, "no re-anchor when media trails the edge"); } fn sid(s: &str) -> StreamId { diff --git a/test/smoke/clients/js/package.json b/test/smoke/clients/js/package.json index 2b6ce7b60..7fda5888c 100644 --- a/test/smoke/clients/js/package.json +++ b/test/smoke/clients/js/package.json @@ -7,7 +7,7 @@ "@moq/watch": "workspace:*" }, "devDependencies": { - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "playwright": "^1.61.0", "vite": "^8.0.16" } diff --git a/uv.lock b/uv.lock index 71a5ff6a5..13c62db66 100644 --- a/uv.lock +++ b/uv.lock @@ -50,26 +50,26 @@ wheels = [ [[package]] name = "maturin" -version = "1.13.3" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/1c/612d23d33ec21b9ae7ece7b3f0dd5f9dfd57b4009e9d2938165869ebd6ae/maturin-1.13.3.tar.gz", hash = "sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079", size = 357934, upload-time = "2026-05-11T07:43:39.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/d0/b7c8b7778cc44df3efbc96eb23acaa995e06ea1a60eb9b02f29858fcbd08/maturin-1.14.0.tar.gz", hash = "sha256:f7f82a6aca4a6c402bf00b99200be199d4874d04b9b9e74e825726a3478bba7f", size = 367010, upload-time = "2026-06-12T00:13:30.811Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/66/18c2aaac0b2a5dea9f1db5984ce83b905ad205cfc7c02d0091e707c0c2e7/maturin-1.13.3-py3-none-linux_armv6l.whl", hash = "sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c", size = 10190971, upload-time = "2026-05-11T07:43:10.431Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/26a988d092e4fd6a9523d46d44400a46cad7cdf3fd206ce702240c748aee/maturin-1.13.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323", size = 19716714, upload-time = "2026-05-11T07:43:36.911Z" }, - { url = "https://files.pythonhosted.org/packages/82/5c/f3fd0e184255d9fc7e272c62af3dfa84c617b2577ef83af9ce615f5279cc/maturin-1.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0", size = 10194726, upload-time = "2026-05-11T07:43:07.05Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e1/f4edb69fb647b77c4769a9bfd4d6fb62961e653d164bc277ecdffac3ab61/maturin-1.13.3-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11", size = 10172781, upload-time = "2026-05-11T07:43:40.796Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7d/a1be934690cdcc3c6609769ceaad322ab7501c2ee5bafcac1b14d609e403/maturin-1.13.3-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f", size = 10682670, upload-time = "2026-05-11T07:43:13.132Z" }, - { url = "https://files.pythonhosted.org/packages/18/f5/372ae19b72ce8f6e37e5864ae4dc5b252ee9fce0619ccc3aa366aa3a7f97/maturin-1.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018", size = 10060363, upload-time = "2026-05-11T07:43:21.107Z" }, - { url = "https://files.pythonhosted.org/packages/cb/5b/c68340cca09368af0df80965dfabed4234205a492a93da00793c7b9aae20/maturin-1.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3", size = 10017551, upload-time = "2026-05-11T07:43:33.916Z" }, - { url = "https://files.pythonhosted.org/packages/28/1e/f90fb2b000bad9e6d850cd5afb88b2f1e2a279cfb4de02ea40078484690e/maturin-1.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a", size = 13301712, upload-time = "2026-05-11T07:43:26.492Z" }, - { url = "https://files.pythonhosted.org/packages/be/58/1670f68a8f04ccd7b90df11047bd9a046585310e84e1967cc9849cd1c5a3/maturin-1.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff", size = 10946765, upload-time = "2026-05-11T07:43:16.135Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ac/00c955c2ef134817b1a7bdaa76b0309e9c5291eb17d9ff88069eecd08bc2/maturin-1.13.3-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273", size = 10388661, upload-time = "2026-05-11T07:43:18.727Z" }, - { url = "https://files.pythonhosted.org/packages/97/c6/cbf8a51dde19c19aeba0d9b075095a2effb9b31fd312b1aae3ac79f8aea2/maturin-1.13.3-py3-none-win32.whl", hash = "sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b", size = 8901838, upload-time = "2026-05-11T07:43:23.76Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ff/c6a50a59dc8313097d43ac5f4d74df6a500c8cb62b0dc9e054f53e203a48/maturin-1.13.3-py3-none-win_amd64.whl", hash = "sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6", size = 10340801, upload-time = "2026-05-11T07:43:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/6c/93/e32e79333f0902ba292b996f504f5f06be59587f7d02ab8d5ed1e3066445/maturin-1.13.3-py3-none-win_arm64.whl", hash = "sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b", size = 9706562, upload-time = "2026-05-11T07:43:31.743Z" }, + { url = "https://files.pythonhosted.org/packages/88/51/49367dcd8f6ec139e69ef0c695c8ff5075223673382101812b4affa53216/maturin-1.14.0-py3-none-linux_armv6l.whl", hash = "sha256:019ea3ec7e71f4c9759a367d4d21022ed5a3a621a2ce123abf3fb114ab3711ca", size = 10204135, upload-time = "2026-06-12T00:13:34.308Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2a/487ce56c838d25e0ce64350e75ec4e3dc89544c0a6233221c229d6aa1a84/maturin-1.14.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6948a10f5f3470b791f79319be51debdd8bfd1778b36f2409f98e1314bc3859b", size = 19736800, upload-time = "2026-06-12T00:13:40.456Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a5/12f2efc18f419edce3282a93629cba16278bb502135dac95cd04ef7c2eae/maturin-1.14.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1506e86b1e273a98074a62e281b13f27ac96f8cdef85f7f98d3e3589a9387a23", size = 10201144, upload-time = "2026-06-12T00:13:26.842Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/3789e72273fd8bc80c33a11c787634b3251c4989d7a7203a92438836d4ff/maturin-1.14.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:df10ce4f7ba97fd3423f624f39b94c888ae3e5b470642a91918e1ccec81282fd", size = 10182394, upload-time = "2026-06-12T00:13:13.693Z" }, + { url = "https://files.pythonhosted.org/packages/40/79/15957eb4e055597f217e6310963a9c1371372e63c5b4a3e30803365addd2/maturin-1.14.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:75bcd4468a7fe597652cc2980c6bb16ce4bb8c411e3eb85dac2c4418cef0e95a", size = 10616603, upload-time = "2026-06-12T00:13:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4b/d1822f88cd5e855640f0e10ee00c39b9be614c1ef2f827e9792332d94b9f/maturin-1.14.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d123337e817f8dfe23755d6760139c01104137bb63e9e20c289c547e25ec857", size = 10075309, upload-time = "2026-06-12T00:13:38.274Z" }, + { url = "https://files.pythonhosted.org/packages/c0/82/c1b160d2163e8784489285e82a5c811fdcef3e0704e35b34c1cfe1828de3/maturin-1.14.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:107f84110d890090a01bb1ecd01761fdfae925c23c659ba492c9b83dd179eab4", size = 10024058, upload-time = "2026-06-12T00:13:16.49Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/88a9d1872997d4535af10ebe79f550e834880bf613cf8e50b50d2d938e3b/maturin-1.14.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:9a84277aa907961cd47ad26fef1539e79efa30611972eaf7499606e773e991b2", size = 13302073, upload-time = "2026-06-12T00:13:29.027Z" }, + { url = "https://files.pythonhosted.org/packages/4a/13/3f6d28bb7b744558b9bc78c995c1855d7e5ff21ad475f46d9de5c3dab039/maturin-1.14.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:095714b2a904927e3c868a1c5d078257ff0443c5049f7623777352966768306e", size = 10863616, upload-time = "2026-06-12T00:13:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/39352d2b402efa3a7dd01d4ed197b301ea35eec10208ba2b8c649101f4df/maturin-1.14.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:20229d332f87166b930e4ca07cdbee8a1726f2eea87a337610aa25bba3ddf4b4", size = 10399943, upload-time = "2026-06-12T00:13:36.273Z" }, + { url = "https://files.pythonhosted.org/packages/58/77/641504541336240fef3836b2d15a785eaeb33c941fb118513c267dd70840/maturin-1.14.0-py3-none-win32.whl", hash = "sha256:4ba1e3c3f33609f461d587b7549104c81a15fd6d42ba63a73cea9376a1e9876e", size = 8905117, upload-time = "2026-06-12T00:13:18.38Z" }, + { url = "https://files.pythonhosted.org/packages/02/4a/ca247a0c43069b2f48cf783c5b13c3a9eb92c8f596dc7fbdb9f75fea4414/maturin-1.14.0-py3-none-win_amd64.whl", hash = "sha256:cb09a313f097adeb4dda0082277871a28d1bd26615dbadab42e6234b6df6fe69", size = 10309099, upload-time = "2026-06-12T00:13:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a4/f14a3f6086cc3caaa90d12e832e4aa41de771c310041959f0d35dd4efe17/maturin-1.14.0-py3-none-win_arm64.whl", hash = "sha256:8c1a8188195f5b6ce1aab99ae2d92e342900298f901456b43ca028947fd3b288", size = 9719100, upload-time = "2026-06-12T00:13:24.741Z" }, ] [[package]] @@ -163,7 +163,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -174,9 +174,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, ] [[package]] @@ -195,27 +195,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.16" +version = "0.15.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, - { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, - { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, - { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, - { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, - { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, - { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, - { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, - { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, - { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] [[package]]