diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..180dce0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,346 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + +jobs: + lockfile: + name: Lockfile consistency + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - name: Install pinned Rust toolchain + run: rustup toolchain install 1.94.0 --profile minimal + - name: Verify lockfile is current + run: cargo metadata --locked --format-version 1 >/dev/null + + format: + name: Format + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.94.0 --profile minimal --component rustfmt + rustc --version + cargo --version + - name: Restore leptosfmt + id: leptosfmt-cache + uses: actions/cache@v5 + with: + path: ~/.cargo/bin/leptosfmt + key: ci-tool-${{ runner.os }}-leptosfmt-0.1.33 + - name: Install Leptos formatter + if: steps.leptosfmt-cache.outputs.cache-hit != 'true' + run: cargo +stable install leptosfmt --version 0.1.33 --locked + - name: Generate canonical formatting + id: format + run: | + cargo fmt --all + while IFS= read -r -d '' f; do + leptosfmt --rustfmt --stdin --quiet < "$f" > "$f.tmp" + mv "$f.tmp" "$f" + done < <(find live-search i18n-demo -name '*.rs' -print0) + cargo fmt --all + + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + git diff --name-only -- '*.rs' > /tmp/formatted-files.txt + tar -czf /tmp/formatted-rust.tar.gz -T /tmp/formatted-files.txt + git diff --stat + fi + - name: Upload formatter output + if: steps.format.outputs.changed == 'true' + uses: actions/upload-artifact@v7 + with: + name: formatted-rust + path: /tmp/formatted-rust.tar.gz + retention-days: 1 + - name: Verify formatting + if: steps.format.outputs.changed == 'true' + run: git diff --exit-code + + clippy: + name: Clippy (native) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + # Unlike sccache, Cargo's target directory can preserve Clippy metadata and + # rustc incremental state. The repository pins rustc, so this is safe to + # reuse across successive commits to the same PR. + env: + CARGO_INCREMENTAL: "1" + steps: + - uses: actions/checkout@v7 + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.94.0 --profile minimal --component clippy + rustc --version + cargo --version + - name: Restore Clippy build cache + uses: actions/cache@v5 + with: + path: | + target/debug/.fingerprint + target/debug/build + target/debug/deps + target/debug/examples + target/debug/incremental + key: clippy-target-v1-${{ runner.os }}-rust-1.94.0-${{ hashFiles('Cargo.lock') }}-${{ github.sha }} + restore-keys: | + clippy-target-v1-${{ runner.os }}-rust-1.94.0-${{ hashFiles('Cargo.lock') }}- + clippy-target-v1-${{ runner.os }}-rust-1.94.0- + - name: Clippy + run: | + cargo clippy --locked --workspace --all-targets -- -D warnings + cargo clippy --locked -p live-search --features ssr --all-targets -- -D warnings + cargo clippy --locked -p live-search --features ssr,otel --all-targets -- -D warnings + cargo clippy --locked -p gateway-example --features otel --all-targets -- -D warnings + cargo clippy --locked -p i18n-demo --features ssr --all-targets -- -D warnings + - name: Report Clippy cache footprint + if: always() + run: du -sh target/debug 2>/dev/null || true + + wasm-check: + name: WASM check (${{ matrix.package }}) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + package: + - live-search + - i18n-demo + steps: + - uses: actions/checkout@v7 + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.94.0 --profile minimal --target wasm32-unknown-unknown + rustc --version + cargo --version + - name: Check hydration build + run: cargo check --locked -p "${{ matrix.package }}" --target wasm32-unknown-unknown --features hydrate --lib + + unit-tests: + name: Unit tests + runs-on: ubuntu-24.04 + timeout-minutes: 30 + # sccache's GHA object backend currently drops writes when concurrent jobs + # hit GitHub's per-run cache-service rate limit. Keep a local sccache store + # and transfer it once through actions/cache instead. + env: + SCCACHE_DIR: ${{ github.workspace }}/.sccache/unit-tests + SCCACHE_CACHE_SIZE: "1G" + RUSTC_WRAPPER: "sccache" + steps: + - uses: actions/checkout@v7 + - name: Set up sccache + uses: mozilla-actions/sccache-action@v0.0.11 + with: + version: "v0.17.0" + - name: Restore unit-test compiler cache + uses: actions/cache@v5 + with: + path: .sccache/unit-tests + key: sccache-unit-v1-${{ runner.os }}-rust-1.94.0-${{ hashFiles('Cargo.lock', '**/Cargo.toml') }} + restore-keys: | + sccache-unit-v1-${{ runner.os }}-rust-1.94.0- + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.94.0 --profile minimal + rustc --version + cargo --version + - name: Run library tests + run: cargo test --locked --workspace --lib + - name: Show sccache stats + if: always() + run: ${SCCACHE_PATH} --show-stats + + audit: + name: Dependency audit + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.94.0 --profile minimal + rustc --version + cargo --version + - name: Restore cargo-audit + id: cargo-audit-cache + uses: actions/cache@v5 + with: + path: ~/.cargo/bin/cargo-audit + key: ci-tool-${{ runner.os }}-cargo-audit-0.22.2 + - name: Install cargo-audit + if: steps.cargo-audit-cache.outputs.cache-hit != 'true' + run: cargo +stable install cargo-audit --version 0.22.2 --locked + # Audit the exact dependency graph committed to the repository. CI must + # not mutate Cargo.lock first: both targeted patched versions are already + # present, and `cargo update -p` can legitimately rewrite unrelated + # transitive edges even when the requested package version is unchanged. + - name: Audit dependencies + run: cargo audit + + leptos-build: + name: Leptos production builds + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + SCCACHE_DIR: ${{ github.workspace }}/.sccache/leptos-build + SCCACHE_CACHE_SIZE: "2G" + RUSTC_WRAPPER: "sccache" + steps: + - uses: actions/checkout@v7 + - name: Set up sccache + uses: mozilla-actions/sccache-action@v0.0.11 + with: + version: "v0.17.0" + - name: Restore production compiler cache + uses: actions/cache@v5 + with: + path: .sccache/leptos-build + key: sccache-leptos-v1-${{ runner.os }}-rust-1.94.0-${{ hashFiles('Cargo.lock', '**/Cargo.toml') }} + restore-keys: | + sccache-leptos-v1-${{ runner.os }}-rust-1.94.0- + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.94.0 --profile minimal --target wasm32-unknown-unknown + rustc --version + cargo --version + - name: Restore Leptos build tools + id: leptos-tools-cache + uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/cargo-leptos + ~/.cargo/bin/stylance + key: ci-tools-${{ runner.os }}-cargo-leptos-0.3.7-stylance-0.8.4 + - name: Install build tools + if: steps.leptos-tools-cache.outputs.cache-hit != 'true' + run: | + cargo +stable install cargo-leptos --version 0.3.7 --locked + cargo +stable install stylance-cli --version 0.8.4 --locked + - name: Build live-search + run: | + cargo leptos build --release -p live-search + mkdir -p target/site/pkg + stylance live-search --output-file target/site/pkg/live-search.css + find target/site/pkg -maxdepth 1 -type f -name '*.js' -size +0c -print -quit | grep -q . + find target/site/pkg -maxdepth 1 -type f -name '*.wasm' -size +0c -print -quit | grep -q . + test -s target/site/pkg/live-search.css + - name: Build i18n-demo + run: cargo leptos build --release -p i18n-demo + - name: Show sccache stats + if: always() + run: ${SCCACHE_PATH} --show-stats + + docker-build: + name: Docker image (${{ matrix.image }}) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - image: live-search + dockerfile: live-search.Dockerfile + tag: rwf-live-search:test + - image: i18n-demo + dockerfile: i18n-demo.Dockerfile + tag: rwf-i18n-demo:test + - image: gateway + dockerfile: gateway.Dockerfile + tag: rwf-gateway:test + env: + DOCKER_BUILD_RECORD_UPLOAD: "false" + steps: + - uses: actions/checkout@v7 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4.3.0 + - name: Build image + uses: docker/build-push-action@v7.3.0 + with: + context: . + file: ${{ matrix.dockerfile }} + tags: ${{ matrix.tag }} + load: true + cache-from: type=gha,scope=docker-${{ matrix.image }} + cache-to: type=gha,mode=max,scope=docker-${{ matrix.image }} + + e2e-tests: + name: Browser + integration E2E + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + SCCACHE_DIR: ${{ github.workspace }}/.sccache/e2e + SCCACHE_CACHE_SIZE: "2G" + RUSTC_WRAPPER: "sccache" + steps: + - uses: actions/checkout@v7 + - name: Set up sccache + uses: mozilla-actions/sccache-action@v0.0.11 + with: + version: "v0.17.0" + - name: Restore E2E compiler cache + uses: actions/cache@v5 + with: + path: .sccache/e2e + key: sccache-e2e-v1-${{ runner.os }}-rust-1.94.0-${{ hashFiles('Cargo.lock', '**/Cargo.toml') }} + restore-keys: | + sccache-e2e-v1-${{ runner.os }}-rust-1.94.0- + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.94.0 --profile minimal --target wasm32-unknown-unknown + rustc --version + cargo --version + - name: Restore E2E build tools + id: e2e-tools-cache + uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/wasm-bindgen + ~/.cargo/bin/stylance + key: ci-tools-${{ runner.os }}-wasm-bindgen-cli-0.2.126-stylance-0.8.4 + - name: Install E2E build tools + if: steps.e2e-tools-cache.outputs.cache-hit != 'true' + run: | + cargo +stable install wasm-bindgen-cli --version 0.2.126 --locked + cargo +stable install stylance-cli --version 0.8.4 --locked + - name: Configure Chrome + shell: bash + run: | + CHROME_PATH="$(command -v google-chrome || command -v chromium || true)" + if [ -z "$CHROME_PATH" ]; then + echo "No Chrome/Chromium executable found on the GitHub-hosted runner" >&2 + exit 1 + fi + echo "CHROME_PATH=$CHROME_PATH" >> "$GITHUB_ENV" + "$CHROME_PATH" --version + - name: Verify Docker + run: docker version + - name: Run E2E suite + run: bash scripts/test-e2e.sh + - name: Show sccache stats + if: always() + run: ${SCCACHE_PATH} --show-stats diff --git a/Cargo.lock b/Cargo.lock index 250c62a..99fc34d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,7 +249,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.2", + "http", "http-body", "http-body-util", "hyper", @@ -283,7 +283,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.2", + "http", "http-body", "http-body-util", "mime", @@ -303,7 +303,7 @@ dependencies = [ "axum", "bytes", "futures-core", - "http 1.4.2", + "http", "http-body", "matchit", "metrics", @@ -336,7 +336,7 @@ dependencies = [ "axum", "futures-core", "futures-util", - "http 1.4.2", + "http", "opentelemetry 0.32.0", "opentelemetry-semantic-conventions 0.32.1", "pin-project-lite", @@ -432,7 +432,7 @@ dependencies = [ "futures-util", "hex", "home", - "http 1.4.2", + "http", "http-body-util", "hyper", "hyper-named-pipe", @@ -759,15 +759,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "config" version = "0.15.25" @@ -842,16 +833,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "console_log" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" -dependencies = [ - "log", - "web-sys", -] - [[package]] name = "const-str" version = "1.1.0" @@ -1433,7 +1414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1448,11 +1429,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1804,27 +1784,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gloo-net" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" -dependencies = [ - "futures-channel", - "futures-core", - "futures-sink", - "gloo-utils 0.2.0", - "http 0.2.12", - "js-sys", - "pin-project", - "serde", - "serde_json", - "thiserror 1.0.69", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "gloo-net" version = "0.6.0" @@ -1835,7 +1794,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils 0.2.0", - "http 1.4.2", + "http", "js-sys", "pin-project", "serde", @@ -1856,7 +1815,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils 0.3.0", - "http 1.4.2", + "http", "js-sys", "pin-project", "serde", @@ -1867,18 +1826,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "gloo-timers" version = "0.4.0" @@ -1948,16 +1895,16 @@ checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.2", + "http", "indexmap 2.14.0", "slab", "tokio", @@ -2098,17 +2045,6 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e" -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.4.2" @@ -2126,7 +2062,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.2", + "http", ] [[package]] @@ -2137,7 +2073,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.2", + "http", "http-body", "pin-project-lite", ] @@ -2201,7 +2137,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http 1.4.2", + "http", "http-body", "httparse", "httpdate", @@ -2233,7 +2169,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.2", + "http", "hyper", "hyper-util", "rustls", @@ -2265,7 +2201,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.2", + "http", "http-body", "hyper", "ipnet", @@ -3157,40 +3093,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "leptos-forms-rs" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf1157407adb0044ab9ee3ad61051f3e0d4663840853dbcdbd403361e5f3ea8b" -dependencies = [ - "chrono", - "console_error_panic_hook", - "console_log", - "gloo-net 0.5.0", - "gloo-timers 0.3.0", - "js-sys", - "lazy_static", - "leptos", - "leptos-forms-rs-macro", - "log", - "regex", - "serde", - "serde_json", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "leptos-forms-rs-macro" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a35c17bea405c2b20da5be3de831eb925be06ab1c009d53707ff90a39b2d0d3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "leptos-struct-table" version = "0.19.0" @@ -3252,7 +3154,7 @@ dependencies = [ "cookie", "default-struct-builder", "futures-util", - "gloo-timers 0.4.0", + "gloo-timers", "js-sys", "lazy_static", "leptos", @@ -3609,10 +3511,9 @@ dependencies = [ "criterion", "futures", "gloo-net 0.7.0", - "gloo-timers 0.4.0", + "gloo-timers", "lepticons", "leptos", - "leptos-forms-rs", "leptos-struct-table", "leptos-use 0.19.0", "leptos-utils", @@ -3854,7 +3755,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.2", + "http", "httparse", "memchr", "mime", @@ -3911,7 +3812,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4057,7 +3958,7 @@ checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" dependencies = [ "async-trait", "bytes", - "http 1.4.2", + "http", "opentelemetry 0.32.0", "reqwest", ] @@ -4068,7 +3969,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ - "http 1.4.2", + "http", "opentelemetry 0.32.0", "opentelemetry-http", "opentelemetry-proto", @@ -4535,7 +4436,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4830,7 +4731,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http 1.4.2", + "http", "http-body", "http-body-util", "hyper", @@ -4961,7 +4862,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5020,7 +4921,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5373,7 +5274,7 @@ dependencies = [ "const_format", "futures", "gloo-net 0.6.0", - "http 1.4.2", + "http", "http-body-util", "hyper", "inventory", @@ -6056,7 +5957,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6074,7 +5975,7 @@ dependencies = [ "etcetera", "ferroid", "futures", - "http 1.4.2", + "http", "itertools 0.14.0", "log", "memchr", @@ -6385,7 +6286,7 @@ dependencies = [ "base64 0.22.1", "bytes", "h2", - "http 1.4.2", + "http", "http-body", "http-body-util", "hyper", @@ -6442,7 +6343,7 @@ dependencies = [ "axum-core", "cookie", "futures-util", - "http 1.4.2", + "http", "parking_lot", "pin-project-lite", "tower-layer", @@ -6459,7 +6360,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.2", + "http", "http-body", "http-body-util", "http-range-header", @@ -6486,7 +6387,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.2", + "http", "http-body", "http-body-util", "http-range-header", @@ -6521,7 +6422,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78" dependencies = [ "async-trait", - "http 1.4.2", + "http", "time", "tokio", "tower-cookies", @@ -6542,7 +6443,7 @@ dependencies = [ "axum-core", "base64 0.22.1", "futures", - "http 1.4.2", + "http", "parking_lot", "rand 0.9.4", "serde", @@ -6574,7 +6475,7 @@ dependencies = [ "axum", "forwarded-header-value", "governor", - "http 1.4.2", + "http", "pin-project", "thiserror 2.0.18", "tonic", @@ -6648,7 +6549,7 @@ version = "0.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a10464b0f9cac62a3e26c08c7c9b93c5ae62f5165792b2e7e90c52940eaabef5" dependencies = [ - "http 1.4.2", + "http", "opentelemetry 0.32.0", "opentelemetry-semantic-conventions 0.32.1", "tracing", @@ -6687,7 +6588,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http 1.4.2", + "http", "httparse", "log", "rand 0.9.4", @@ -6704,7 +6605,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.2", + "http", "httparse", "log", "rand 0.9.4", @@ -6873,7 +6774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ "base64 0.22.1", - "http 1.4.2", + "http", "httparse", "log", ] @@ -7338,7 +7239,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ac8d45d..43c7296 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ serde_json = "1.0" # dependency/lockfile upgrade is intentionally kept out of this correctness PR # so `--locked` builds remain reproducible. Upgrade Cargo.toml and Cargo.lock # together in the dependency-refresh pass. -sqlx = { version = "0.9", features = ["postgres", "runtime-tokio", "tls-rustls", "json", "macros", "migrate", "chrono", "uuid"] } +sqlx = { version = "0.9", default-features = false, features = ["postgres", "runtime-tokio", "tls-rustls", "json", "macros", "migrate", "chrono", "uuid"] } subtle = "2.6" thiserror = "2.0.18" tokio = { version = "1.52", features = ["full"] } @@ -54,7 +54,6 @@ stylance = "0.8" leptos-use = "0.19" leptos-struct-table = "0.19" lepticons = "0.13" -leptos-forms-rs = "1" utoipa = "5" utoipa-axum = "0.2" # `axum` enables the SwaggerUi router builder; `vendored` bundles Swagger UI assets so Docker builds don't need network. @@ -113,4 +112,4 @@ debug = true strip = false [profile.dev.package."*"] -opt-level = 3 \ No newline at end of file +opt-level = 3 diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index b1ce58a..b2636b6 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -238,6 +238,7 @@ impl Config { ) .add_source( config::Environment::with_prefix("RWF") + .prefix_separator("_") .separator("__") .try_parsing(true), ); diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 7be04e6..fef9609 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -106,9 +106,7 @@ mod tests { #[test] fn serde_json_roundtrips_as_plain_uuid_string() -> Result<(), Box> { - let id = UserId::try_from(Uuid::from_u128( - 0x1234_5678_9ABC_DEF0_1234_5678_9ABC_DEF0, - ))?; + let id = UserId::try_from(Uuid::from_u128(0x1234_5678_9ABC_DEF0_1234_5678_9ABC_DEF0))?; let json = serde_json::to_string(&id)?; assert_eq!(json, "\"12345678-9abc-def0-1234-56789abcdef0\""); diff --git a/crates/leptos-utils/src/lib.rs b/crates/leptos-utils/src/lib.rs index 2c0f69f..fe72181 100644 --- a/crates/leptos-utils/src/lib.rs +++ b/crates/leptos-utils/src/lib.rs @@ -18,4 +18,4 @@ /// /// Kept so callers that mounted the old doubled-prefix probe keep compiling; /// new code should call [`leptos_axum::handle_server_fns`] directly. -pub use leptos_axum::handle_server_fns; \ No newline at end of file +pub use leptos_axum::handle_server_fns; diff --git a/e2e-tests/src/common/db.rs b/e2e-tests/src/common/db.rs index 01044a8..b2a8113 100644 --- a/e2e-tests/src/common/db.rs +++ b/e2e-tests/src/common/db.rs @@ -55,8 +55,7 @@ impl TestEnv { .await .context("Failed to get Postgres host port")?; - let connection_string = - format!("postgres://postgres:postgres@{host}:{host_port}/postgres"); + let connection_string = format!("postgres://postgres:postgres@{host}:{host_port}/postgres"); let pool = PgPool::connect(&connection_string) .await diff --git a/e2e-tests/src/common/live_search_env.rs b/e2e-tests/src/common/live_search_env.rs index dab3b00..ead8895 100644 --- a/e2e-tests/src/common/live_search_env.rs +++ b/e2e-tests/src/common/live_search_env.rs @@ -57,6 +57,15 @@ impl LiveSearchEnv { Self::start_with(LiveSearchConfig::default()).await } + /// Start the production live-search route tree with explicit fixture configuration. + /// + /// # Errors + /// Returns an error if Postgres, Leptos configuration, application state, + /// an explicitly configured asset directory, binding, or readiness fails. + #[expect( + clippy::too_many_lines, + reason = "fixture setup keeps the production route tree and background services together" + )] pub async fn start_with(cfg: LiveSearchConfig) -> Result { let db = super::db::TestEnv::postgres().await?; let conn_str = db.connection_string().to_string(); @@ -78,7 +87,17 @@ impl LiveSearchEnv { )); state::set(Arc::clone(&ctx)).context("live-search AppContext already initialized")?; - let conf = get_configuration(None).context("failed to load Leptos configuration")?; + // The fixture is not launched by cargo-leptos, so load the application's + // metadata directly instead of relying on LEPTOS_* environment variables. + // In particular, LEPTOS_OUTPUT_NAME at compile time changes the WASM name + // HydrationScripts emits and does not match manual wasm-bindgen output. + let leptos_manifest = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../live-search/Cargo.toml"); + let leptos_manifest = leptos_manifest + .to_str() + .context("live-search Cargo.toml path is not valid UTF-8")?; + let conf = get_configuration(Some(leptos_manifest)) + .context("failed to load live-search Leptos configuration")?; let leptos_options = conf.leptos_options; let leptos_routes = generate_route_list(live_search::app::App); @@ -222,7 +241,7 @@ impl LiveSearchEnv { } if std::time::Instant::now() >= deadline { return Err(anyhow::anyhow!( - "Live-search server at {base_url} did not become ready within 30s" + "live-search server at {base_url} did not become ready within 30s" )); } tokio::time::sleep(std::time::Duration::from_millis(200)).await; @@ -253,7 +272,7 @@ impl std::fmt::Debug for LiveSearchEnv { .field("base_url", &self.base_url) .field("db_container", &self.db_container) .field("shutdown", &"") - .finish() + .finish_non_exhaustive() } } diff --git a/e2e-tests/tests/live_search_test.rs b/e2e-tests/tests/live_search_test.rs index 0a1c187..65fd27a 100644 --- a/e2e-tests/tests/live_search_test.rs +++ b/e2e-tests/tests/live_search_test.rs @@ -2,7 +2,7 @@ //! //! HTTP tests exercise the same Leptos route tree as production against an //! isolated Postgres testcontainer. Browser tests additionally exercise WASM -//! hydration and the full PostgreSQL -> LISTEN/NOTIFY -> SSE -> browser path. +//! hydration and the full `PostgreSQL` -> LISTEN/NOTIFY -> SSE -> browser path. use std::time::Duration; @@ -10,9 +10,9 @@ use anyhow::Context; #[cfg(feature = "browser-tests")] use chromiumoxide::Page; +use e2e_tests::common::{LiveSearchEnv, SharedServer}; #[cfg(feature = "browser-tests")] use e2e_tests::common::{element_is_visible, setup, teardown, wait_for_element, wait_for_js_true}; -use e2e_tests::common::{LiveSearchEnv, SharedServer}; static SERVER: SharedServer = SharedServer::new(); @@ -143,7 +143,10 @@ mod browser_tests { Duration::from_secs(10), ) .await; - assert!(no_results, "Expected 'No results found.' for nonsense query"); + assert!( + no_results, + "Expected 'No results found.' for nonsense query" + ); let count: u32 = ctx .page @@ -242,7 +245,10 @@ mod browser_tests { Duration::from_secs(10), ) .await; - assert!(sentinel_appeared, "Sentinel did not reach the browser via SSE"); + assert!( + sentinel_appeared, + "Sentinel did not reach the browser via SSE" + ); if let Err(e) = sqlx::query("DELETE FROM search_results WHERE title = $1") .bind(&title) @@ -336,6 +342,14 @@ async fn root_path_is_real_ssr_page() -> anyhow::Result<()> { body.contains("data-testid=\"search-input\"") || body.contains("Enter search query"), "root did not render the production search page" ); + anyhow::ensure!( + body.contains("/pkg/live-search.js"), + "SSR hydration loader did not reference /pkg/live-search.js" + ); + anyhow::ensure!( + body.contains("/pkg/live-search_bg.wasm"), + "SSR hydration loader did not reference /pkg/live-search_bg.wasm" + ); Ok(()) } @@ -345,24 +359,33 @@ async fn static_assets_are_served() -> anyhow::Result<()> { let pkg_dir = std::env::var_os("LIVE_SEARCH_PKG_DIR") .map(std::path::PathBuf::from) .context("LIVE_SEARCH_PKG_DIR is required for browser tests")?; - let pkg_path = pkg_dir.join("live_search.js"); - anyhow::ensure!(pkg_path.exists(), "missing {}", pkg_path.display()); + let js_path = pkg_dir.join("live-search.js"); + let wasm_path = pkg_dir.join("live-search_bg.wasm"); + anyhow::ensure!(js_path.exists(), "missing {}", js_path.display()); + anyhow::ensure!(wasm_path.exists(), "missing {}", wasm_path.display()); let env = get_server().await?; - let url = format!("{}/pkg/live_search.js", env.base_url()); - let response = reqwest::get(&url).await?; - anyhow::ensure!(response.status() == 200); - let content_type = response + let js_url = format!("{}/pkg/live-search.js", env.base_url()); + let js_response = reqwest::get(&js_url).await?; + anyhow::ensure!(js_response.status() == 200); + let content_type = js_response .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_string(); + anyhow::ensure!(content_type.contains("javascript") || content_type.contains("text/plain")); + let bytes = js_response.bytes().await?; + anyhow::ensure!(bytes.len() > 1024, "hydration JS is suspiciously small"); + + let wasm_url = format!("{}/pkg/live-search_bg.wasm", env.base_url()); + let wasm_response = reqwest::get(&wasm_url).await?; + anyhow::ensure!(wasm_response.status() == 200); + let wasm_bytes = wasm_response.bytes().await?; anyhow::ensure!( - content_type.contains("javascript") || content_type.contains("text/plain") + wasm_bytes.len() > 1024, + "hydration WASM is suspiciously small" ); - let bytes = response.bytes().await?; - anyhow::ensure!(bytes.len() > 1024, "hydration JS is suspiciously small"); Ok(()) } diff --git a/gateway.Dockerfile b/gateway.Dockerfile index 7028a06..61053b3 100644 --- a/gateway.Dockerfile +++ b/gateway.Dockerfile @@ -4,6 +4,10 @@ # across the whole workspace. FROM rust:1.94-bookworm@sha256:6ae102bdbf528294bc79ad6e1fae682f6f7c2a6e6621506ba959f9685b308a55 AS chef RUN cargo install cargo-chef --locked +# Keep the dependency cook and final source build on the same rustup toolchain. +# Otherwise the later COPY of rust-toolchain.toml can change Cargo's compiler +# identity and invalidate the cooked dependency artifacts. +ENV RUSTUP_TOOLCHAIN=1.94.0 WORKDIR /build FROM chef AS planner @@ -11,16 +15,13 @@ COPY . . RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder -RUN cargo install sccache --locked && \ - cargo install leptosfmt --locked -ENV RUSTC_WRAPPER=sccache COPY --from=planner /build/recipe.json recipe.json -RUN --mount=type=cache,target=/var/cache/sccache \ - cargo chef cook --recipe-path recipe.json --locked --release \ - --bin gateway-example +# Match the final package selection exactly so Cargo can reuse the cooked +# library + binary dependency graph after the real sources are copied in. +RUN cargo chef cook --recipe-path recipe.json --locked --release \ + --package gateway-example COPY . . -RUN --mount=type=cache,target=/var/cache/sccache \ - cargo build --locked --release -p gateway-example +RUN cargo build --locked --release -p gateway-example FROM debian:bookworm-slim@sha256:60eac759739651111db372c07be67863818726f754804b8707c90979bda511df RUN groupadd -r app && \ diff --git a/gateway/benches/auth_bench.rs b/gateway/benches/auth_bench.rs index 5f4c2d4..5a2e6ae 100644 --- a/gateway/benches/auth_bench.rs +++ b/gateway/benches/auth_bench.rs @@ -40,7 +40,9 @@ fn bench_sign_verify(c: &mut Criterion) { let decoding_key = DecodingKey::from_ed_pem(public_pem.as_bytes()).expect("valid Ed25519 public key PEM"); - let bench_user_id = UserId::new(Uuid::from_u128(0xDEAD_BEEF_CAFE_BABE_0123_4567_89AB_CDEF)); + let bench_user_id = + UserId::try_from(Uuid::from_u128(0xDEAD_BEEF_CAFE_BABE_0123_4567_89AB_CDEF)) + .expect("benchmark user id is non-nil"); c.bench_function("sign_verify", |b| { b.iter(|| { diff --git a/gateway/src/auth/handlers.rs b/gateway/src/auth/handlers.rs index 405a062..c04daef 100644 --- a/gateway/src/auth/handlers.rs +++ b/gateway/src/auth/handlers.rs @@ -79,6 +79,12 @@ pub struct ProtectedResponse { ), tag = "auth", )] +/// Authenticate the configured admin and create access/refresh credentials. +/// +/// # Errors +/// Returns an authentication error for an invalid user ID or password, and an +/// internal error if JWT creation, refresh-token persistence, or session-store +/// persistence fails. pub async fn login_handler( State(state): State, session: tower_sessions::Session, @@ -106,7 +112,9 @@ pub async fn login_handler( let pool = state.db_pool.as_ref().ok_or_else(|| { AppError::internal( "refresh-token store unavailable", - std::io::Error::other("DATABASE_URL not configured; gateway cannot issue refresh tokens"), + std::io::Error::other( + "DATABASE_URL not configured; gateway cannot issue refresh tokens", + ), ) })?; @@ -159,6 +167,12 @@ pub async fn login_handler( ), tag = "auth", )] +/// Rotate a refresh token and issue a new access token. +/// +/// # Errors +/// Returns an authentication error for an invalid, expired, revoked, or +/// replayed refresh token, and an internal error if token rotation, database +/// access, or JWT creation fails. pub async fn refresh_handler( State(state): State, axum_valid::Valid(axum::Json(RefreshRequest { refresh_token })): axum_valid::Valid< @@ -173,15 +187,10 @@ pub async fn refresh_handler( })?; let now = chrono::Utc::now(); - let rotation = super::refresh::rotate( - pool, - &refresh_token, - now, - state.refresh_token_ttl_secs, - ) - .await - .map_err(|e| AppError::internal("refresh-token rotation", e))? - .ok_or(AppError::AuthError)?; + let rotation = super::refresh::rotate(pool, &refresh_token, now, state.refresh_token_ttl_secs) + .await + .map_err(|e| AppError::internal("refresh-token rotation", e))? + .ok_or(AppError::AuthError)?; let token = create_jwt( &rotation.subject, @@ -204,6 +213,11 @@ pub async fn refresh_handler( ), tag = "auth", )] +/// Revoke outstanding refresh tokens for the authenticated subject. +/// +/// # Errors +/// Returns an internal error if the refresh-token store is unavailable or the +/// revocation query fails. pub async fn logout_handler( State(state): State, Extension(claims): Extension, diff --git a/gateway/src/auth/jwt.rs b/gateway/src/auth/jwt.rs index a9a55ed..5027890 100644 --- a/gateway/src/auth/jwt.rs +++ b/gateway/src/auth/jwt.rs @@ -126,7 +126,7 @@ mod tests { let encoding_key = EncodingKey::from_ed_pem(private_pem.as_bytes())?; let decoding_key = DecodingKey::from_ed_pem(public_pem.as_bytes())?; - let user_id = UserId::new(Uuid::new_v4()); + let user_id = UserId::try_from(Uuid::new_v4())?; let token = create_jwt(&user_id, &encoding_key, 60 * 60 * 24)?; let claims = validate_jwt(&token, &decoding_key)?; @@ -152,7 +152,7 @@ mod tests { ); let wrong_decoding_key = DecodingKey::from_ed_pem(wrong_public_pem.as_bytes())?; - let user_id = UserId::new(Uuid::new_v4()); + let user_id = UserId::try_from(Uuid::new_v4())?; let token = create_jwt(&user_id, &encoding_key, 60 * 60 * 24)?; let result = validate_jwt(&token, &wrong_decoding_key); @@ -174,7 +174,7 @@ mod tests { fn rejects_expired_token() -> anyhow::Result<()> { let (private_pem, public_pem) = dev_keypair_pems()?; - let user_id = UserId::new(Uuid::new_v4()); + let user_id = UserId::try_from(Uuid::new_v4())?; let expired = Claims { sub: user_id, exp: 0, diff --git a/gateway/src/cors.rs b/gateway/src/cors.rs index 61edeba..78cdc81 100644 --- a/gateway/src/cors.rs +++ b/gateway/src/cors.rs @@ -15,7 +15,6 @@ fn credentialed(origins: Vec) -> CorsLayer { /// /// `*` is deliberately non-credentialed. Concrete origin lists are /// credential-enabled so session cookies work across configured origins. -#[must_use] pub fn cors_layer(allowed_origins: &str) -> CorsLayer { let trimmed = allowed_origins.trim(); diff --git a/gateway/src/gateway.rs b/gateway/src/gateway.rs index 631ba40..19bf215 100644 --- a/gateway/src/gateway.rs +++ b/gateway/src/gateway.rs @@ -92,10 +92,6 @@ pub fn build_gateway(modules: Vec>) -> Result>, mut settings: settings::Settings, @@ -224,8 +220,7 @@ pub fn build_gateway_with_settings( .merge(crate::openapi::swagger_ui_router()) .layer(general_governor); - let session_router = - crate::session::router::().route_layer(csrf_middleware.clone()); + let session_router = crate::session::router::().route_layer(csrf_middleware); async fn csrf_token_handler( session: tower_sessions::Session, @@ -262,9 +257,7 @@ pub fn build_gateway_with_settings( .with_state(state); #[cfg(feature = "otel")] - let app = app.layer( - axum_tracing_opentelemetry::middleware::OtelAxumLayer::default(), - ); + let app = app.layer(axum_tracing_opentelemetry::middleware::OtelAxumLayer::default()); Ok(app) } @@ -285,36 +278,32 @@ pub const REFRESH_BURST_SIZE: u32 = 5; )] #[instrument(skip(state))] pub async fn health_handler(State(state): State) -> (StatusCode, Json) { - let services = join_all( - state - .modules - .iter() - .filter(|module| module.enabled()) - .map(|module| async { - let status = - match tokio::time::timeout(HEALTH_CHECK_TIMEOUT, module.health_check()).await { - Ok(Ok(())) => "healthy", - Ok(Err(e)) => { - tracing::warn!(name = module.name(), error = %e, "health check failed"); - "unhealthy" - } - Err(_) => { - tracing::warn!( - name = module.name(), - timeout_ms = HEALTH_CHECK_TIMEOUT.as_millis(), - "health check timed out" - ); - "unhealthy" - } - }; - json!({ - "name": module.name(), - "path": module.path(), - "enabled": module.enabled(), - "status": status, - }) - }), - ) + let services = join_all(state.modules.iter().filter(|module| module.enabled()).map( + |module| async { + let status = + match tokio::time::timeout(HEALTH_CHECK_TIMEOUT, module.health_check()).await { + Ok(Ok(())) => "healthy", + Ok(Err(e)) => { + tracing::warn!(name = module.name(), error = %e, "health check failed"); + "unhealthy" + } + Err(_) => { + tracing::warn!( + name = module.name(), + timeout_ms = HEALTH_CHECK_TIMEOUT.as_millis(), + "health check timed out" + ); + "unhealthy" + } + }; + json!({ + "name": module.name(), + "path": module.path(), + "enabled": module.enabled(), + "status": status, + }) + }, + )) .await; let database_status = match state.db_pool.as_ref() { @@ -338,8 +327,8 @@ pub async fn health_handler(State(state): State) -> (StatusCode, J None => "not_configured", }; - let any_unhealthy = services.iter().any(|result| result["status"] != "healthy") - || database_status != "healthy"; + let any_unhealthy = + services.iter().any(|result| result["status"] != "healthy") || database_status != "healthy"; let http_status = if any_unhealthy { StatusCode::SERVICE_UNAVAILABLE } else { @@ -398,7 +387,8 @@ mod tests { } #[tokio::test] - async fn health_endpoint_returns_503_when_service_or_database_unhealthy() -> anyhow::Result<()> { + async fn health_endpoint_returns_503_when_service_or_database_unhealthy() -> anyhow::Result<()> + { let (tx, _rx) = tokio::sync::broadcast::channel(100); let settings = crate::settings::Settings::load_dev_keys("test-admin-password")?; let modules: Vec> = vec![Arc::new(FailingService)]; diff --git a/gateway/src/main.rs b/gateway/src/main.rs index 518fbc1..f868a6b 100644 --- a/gateway/src/main.rs +++ b/gateway/src/main.rs @@ -13,9 +13,7 @@ use rwf_config::Config; async fn main() -> anyhow::Result<()> { let dev_keys = std::env::args().any(|arg| arg == "--dev-keys"); if dev_keys && std::env::var("ALLOW_DEV_KEYS").ok().as_deref() != Some("1") { - anyhow::bail!( - "--dev-keys requires ALLOW_DEV_KEYS=1 to be set; refusing to start" - ); + anyhow::bail!("--dev-keys requires ALLOW_DEV_KEYS=1 to be set; refusing to start"); } // Non-secret runtime configuration has one canonical source: rwf-config. diff --git a/gateway/src/services/proxy.rs b/gateway/src/services/proxy.rs index 511c16e..b54f85c 100644 --- a/gateway/src/services/proxy.rs +++ b/gateway/src/services/proxy.rs @@ -55,6 +55,7 @@ impl ServiceModule for ProxyService { #[serde(deny_unknown_fields)] pub struct ProxyCheckQuery { /// IP to inspect. Defaults to Google's public DNS address for the demo. + #[param(value_type = Option)] pub ip: Option, } @@ -98,9 +99,7 @@ async fn check_handler( State(state): State, Query(params): Query, ) -> Result, AppError> { - let parsed_ip = params - .ip - .unwrap_or(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))); + let parsed_ip = params.ip.unwrap_or(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))); let base = state.proxy_upstream_url.trim_end_matches('/'); let url = format!("{base}/{parsed_ip}/json/"); diff --git a/gateway/src/settings.rs b/gateway/src/settings.rs index 6ee7ff2..4aa470b 100644 --- a/gateway/src/settings.rs +++ b/gateway/src/settings.rs @@ -148,8 +148,8 @@ impl Settings { .map_err(|e| anyhow::anyhow!("failed to parse EdDSA public key PEM: {e}"))?, ); - let admin_user_id_raw = std::env::var("ADMIN_USER_ID") - .unwrap_or_else(|_| DEFAULT_ADMIN_USER_ID.to_string()); + let admin_user_id_raw = + std::env::var("ADMIN_USER_ID").unwrap_or_else(|_| DEFAULT_ADMIN_USER_ID.to_string()); let admin_user_id = UserId::from_str(&admin_user_id_raw) .with_context(|| format!("invalid ADMIN_USER_ID {admin_user_id_raw:?}"))?; @@ -233,6 +233,11 @@ impl Settings { }) } + /// Load development settings using `ADMIN_PASSWORD` from the environment. + /// + /// # Errors + /// Returns an error if `ADMIN_PASSWORD` is missing or development key + /// generation/encoding fails. pub fn load_dev_keys_from_env() -> Result { let admin_password = std::env::var("ADMIN_PASSWORD") .map_err(|_| anyhow::anyhow!("ADMIN_PASSWORD must be set when --dev-keys is used"))?; diff --git a/i18n-demo.Dockerfile b/i18n-demo.Dockerfile index 71df5aa..6e38d07 100644 --- a/i18n-demo.Dockerfile +++ b/i18n-demo.Dockerfile @@ -3,6 +3,9 @@ # Multi-stage build for the i18n-demo Leptos crate. FROM rust:1.94-bookworm@sha256:6ae102bdbf528294bc79ad6e1fae682f6f7c2a6e6621506ba959f9685b308a55 AS chef RUN cargo install cargo-chef --locked +# Keep cargo-chef and the real build on one compiler identity even after the +# repository's rust-toolchain.toml is copied into the workspace. +ENV RUSTUP_TOOLCHAIN=1.94.0 WORKDIR /build FROM chef AS planner @@ -11,19 +14,16 @@ RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder RUN rustup target add wasm32-unknown-unknown && \ - cargo install wasm-bindgen-cli --version 0.2.126 --locked && \ - cargo install sccache --locked && \ - cargo install leptosfmt --locked -ENV RUSTC_WRAPPER=sccache + cargo install wasm-bindgen-cli --version 0.2.126 --locked COPY --from=planner /build/recipe.json recipe.json -RUN --mount=type=cache,target=/var/cache/sccache \ +# Keep wasm/native dependency cooks scoped to this crate. A workspace-wide wasm +# cook enables native networking dependencies from unrelated members. +RUN cargo chef cook --recipe-path recipe.json --locked --release \ + --package i18n-demo --target wasm32-unknown-unknown --no-default-features --features hydrate && \ cargo chef cook --recipe-path recipe.json --locked --release \ - --target wasm32-unknown-unknown --features i18n-demo/hydrate && \ - cargo chef cook --recipe-path recipe.json --locked --release \ - --features i18n-demo/ssr + --package i18n-demo --no-default-features --features ssr COPY . . -RUN --mount=type=cache,target=/var/cache/sccache \ - cargo build --locked --release -p i18n-demo --lib \ +RUN cargo build --locked --release -p i18n-demo --lib \ --target wasm32-unknown-unknown --features hydrate && \ mkdir -p /build/site/pkg && \ wasm-bindgen \ @@ -31,8 +31,7 @@ RUN --mount=type=cache,target=/var/cache/sccache \ --out-dir /build/site/pkg \ --out-name i18n_demo \ /build/target/wasm32-unknown-unknown/release/i18n_demo.wasm -RUN --mount=type=cache,target=/var/cache/sccache \ - cargo build --locked --release -p i18n-demo --features ssr +RUN cargo build --locked --release -p i18n-demo --features ssr FROM debian:bookworm-slim@sha256:60eac759739651111db372c07be67863818726f754804b8707c90979bda511df RUN groupadd -r app && \ @@ -45,7 +44,7 @@ COPY --from=builder /build/target/release/i18n-demo /app/ COPY --from=builder /build/site/pkg /app/site/pkg # i18n-demo uses handwritten CSS (no Stylance); copy it alongside. COPY i18n-demo/src/styles /app/site/pkg/styles -RUN mkdir -p /app/pkg && cp -r /app/site/pkg/* /app/pkg/ 2>/dev/null || true +RUN mkdir -p /app/pkg && cp -r /app/site/pkg/* /app/pkg/ ENV LEPTOS_OUTPUT_NAME=i18n_demo ENV LEPTOS_SITE_PKG_DIR=/app/pkg USER app diff --git a/i18n-demo/src/app.rs b/i18n-demo/src/app.rs index ceff26b..5741257 100644 --- a/i18n-demo/src/app.rs +++ b/i18n-demo/src/app.rs @@ -46,7 +46,7 @@ fn set_document_lang(lang: &str) { } #[cfg(not(target_arch = "wasm32"))] -fn set_document_lang(_lang: &str) {} +const fn set_document_lang(_lang: &str) {} #[expect( clippy::must_use_candidate, diff --git a/i18n-demo/src/lib.rs b/i18n-demo/src/lib.rs index a125dfc..685f6c0 100644 --- a/i18n-demo/src/lib.rs +++ b/i18n-demo/src/lib.rs @@ -11,10 +11,12 @@ pub mod app; pub mod i18n; pub mod styles; + +#[cfg(not(target_arch = "wasm32"))] pub mod ws_chat; // --------------------------------------------------------------------------- -// Hydrate entry point – called by the browser after the WASM module loads. +// Hydrate entry point – called by Leptos HydrationScripts after WASM init. // --------------------------------------------------------------------------- /// Entry point for WASM hydration. @@ -22,7 +24,7 @@ pub mod ws_chat; /// Initialises the panic hook and hydrates the server-rendered HTML to make the /// page interactive (attaches event handlers, starts the reactive system). #[cfg(feature = "hydrate")] -#[wasm_bindgen::prelude::wasm_bindgen(start)] +#[wasm_bindgen::prelude::wasm_bindgen] pub fn hydrate() { console_error_panic_hook::set_once(); leptos::mount::hydrate_body(app::App); diff --git a/i18n-demo/src/ws_chat.rs b/i18n-demo/src/ws_chat.rs index 0630513..8e77e08 100644 --- a/i18n-demo/src/ws_chat.rs +++ b/i18n-demo/src/ws_chat.rs @@ -64,6 +64,10 @@ fn origin_matches_host(origin: &str, host: &str) -> bool { /// Browser-originated upgrades must be same-origin. The protocol layer rejects /// oversized frames/messages before application code receives them; the /// in-loop length check remains as defense in depth. +#[allow( + clippy::unused_async, + reason = "Axum handler entry point is intentionally async" +)] pub async fn chat_handler(headers: HeaderMap, ws: WebSocketUpgrade) -> Response { if let Some(origin_value) = headers.get(ORIGIN) { let Some(host) = headers.get(HOST).and_then(|value| value.to_str().ok()) else { @@ -161,7 +165,10 @@ mod tests { #[test] fn origin_validation_accepts_same_host_only() { - assert!(origin_matches_host("http://localhost:3002", "localhost:3002")); + assert!(origin_matches_host( + "http://localhost:3002", + "localhost:3002" + )); assert!(origin_matches_host("https://example.com", "example.com")); assert!(!origin_matches_host("https://evil.example", "example.com")); assert!(!origin_matches_host("null", "example.com")); @@ -178,7 +185,10 @@ mod tests { } #[test] - #[expect(clippy::panic, reason = "serde failure would make this test fixture invalid")] + #[expect( + clippy::panic, + reason = "serde failure would make this test fixture invalid" + )] fn event_round_trips_through_serde_json() { let event = ChatEvent::new(Uuid::new_v4(), "hello, room".to_string()); let json = match serde_json::to_string(&event) { diff --git a/live-search.Dockerfile b/live-search.Dockerfile index 48615d9..af750d8 100644 --- a/live-search.Dockerfile +++ b/live-search.Dockerfile @@ -1,6 +1,15 @@ # Pinned by digest for reproducible builds. +# +# Multi-stage build: +# 1. chef: install cargo-chef once, reused by planner + builder. +# 2. planner: produce a recipe.json over the whole workspace. +# 3. builder: cargo chef cook the recipe (cached dependency build), +# then copy source and build the binary. FROM rust:1.94-bookworm@sha256:6ae102bdbf528294bc79ad6e1fae682f6f7c2a6e6621506ba959f9685b308a55 AS chef RUN cargo install cargo-chef --locked +# Keep cargo-chef and the real build on one compiler identity even after the +# repository's rust-toolchain.toml is copied into the workspace. +ENV RUSTUP_TOOLCHAIN=1.94.0 WORKDIR /build FROM chef AS planner @@ -10,18 +19,21 @@ RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder RUN rustup target add wasm32-unknown-unknown && \ cargo install wasm-bindgen-cli --version 0.2.126 --locked && \ - cargo install stylance-cli --locked && \ - cargo install sccache --locked -ENV RUSTC_WRAPPER=sccache + cargo install stylance-cli --locked COPY --from=planner /build/recipe.json recipe.json -RUN --mount=type=cache,target=/var/cache/sccache \ +# Stylance's import_style proc macro reads this file at compile time. cargo-chef +# recipes contain Rust manifests/skeletons, not arbitrary CSS assets, so make +# the stylesheet available before the dependency cook as well as the real build. +COPY live-search/src/styles.module.css /build/live-search/src/styles.module.css +# Scope each dependency pre-build to live-search. Cooking the whole workspace +# for wasm32 pulls native Tokio/Mio networking through unrelated members, and +# Mio deliberately does not support wasm32 with its net feature enabled. +RUN cargo chef cook --recipe-path recipe.json --locked --release \ + --package live-search --target wasm32-unknown-unknown --no-default-features --features hydrate && \ cargo chef cook --recipe-path recipe.json --locked --release \ - --target wasm32-unknown-unknown --features live-search/hydrate && \ - cargo chef cook --recipe-path recipe.json --locked --release \ - --features live-search/ssr + --package live-search --no-default-features --features ssr COPY . . -RUN --mount=type=cache,target=/var/cache/sccache \ - cargo build --locked --release -p live-search --lib \ +RUN cargo build --locked --release -p live-search --lib \ --target wasm32-unknown-unknown --features hydrate && \ mkdir -p /build/site/pkg && \ wasm-bindgen \ @@ -31,8 +43,7 @@ RUN --mount=type=cache,target=/var/cache/sccache \ /build/target/wasm32-unknown-unknown/release/live_search.wasm && \ stylance live-search --output-file /build/site/pkg/live-search.css && \ test -s /build/site/pkg/live-search.css -RUN --mount=type=cache,target=/var/cache/sccache \ - cargo build --locked --release -p live-search --features ssr +RUN cargo build --locked --release -p live-search --features ssr FROM debian:bookworm-slim@sha256:60eac759739651111db372c07be67863818726f754804b8707c90979bda511df RUN groupadd -r app && \ @@ -42,9 +53,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libssl3 ca-certificates wget && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=builder /build/target/release/live-search /app/ -COPY --from=builder /build/site/pkg /app/pkg +COPY --from=builder /build/site/pkg /app/site/pkg # SQLx migrations are embedded at compile time from the workspace-level # `migrations/` directory; no runtime migration files are required. +RUN mkdir -p /app/pkg && cp /app/site/pkg/live-search.css /app/pkg/ && \ + cp /app/site/pkg/live_search.js /app/pkg/ && \ + cp /app/site/pkg/live_search_bg.wasm /app/pkg/ ENV LEPTOS_OUTPUT_NAME=live_search ENV LEPTOS_SITE_PKG_DIR=/app/pkg USER app diff --git a/live-search/Cargo.toml b/live-search/Cargo.toml index 9636955..9bb71cd 100644 --- a/live-search/Cargo.toml +++ b/live-search/Cargo.toml @@ -44,9 +44,6 @@ stylance = { workspace = true } # Shared domain types — no platform-specific dependencies, available everywhere. rwf-domain = { workspace = true } -# leptos-forms-rs server-function config — enables the typed form pattern -leptos-forms-rs = { version = "1" } - [target.'cfg(not(target_arch = "wasm32"))'.dependencies] anyhow = { workspace = true } uuid = { workspace = true, features = ["v4"] } @@ -61,7 +58,6 @@ tower-http = { workspace = true, features = ["trace", "fs"] } thiserror = { workspace = true } axum-prometheus = { workspace = true } moka = { workspace = true } -leptos-forms-rs = { version = "1", features = ["ssr"] } base64 = { workspace = true } rwf-config = { path = "../crates/config" } leptos-utils = { workspace = true } @@ -84,12 +80,11 @@ gloo-net = { workspace = true } gloo-timers = { version = "0.4", features = ["futures"] } wasm-bindgen = { workspace = true } console_error_panic_hook = { workspace = true } -leptos-forms-rs = { version = "1", features = ["hydrate"] } rwf-domain = { workspace = true } [features] default = [] -ssr = ["dep:leptos_axum", "leptos/ssr"] +ssr = ["dep:leptos_axum", "leptos/ssr", "leptos-use/ssr"] hydrate = ["leptos/hydrate"] test-seams = [] otel = [ diff --git a/live-search/src/app.rs b/live-search/src/app.rs index 4fa1612..3ea4c64 100644 --- a/live-search/src/app.rs +++ b/live-search/src/app.rs @@ -17,12 +17,6 @@ use lepticons::{Icon, LucideGlyph}; use leptos_struct_table::{EventHandler, TableContent, TableDataProvider, TableRow}; use leptos_use::watch_debounced; -use leptos_forms_rs::core::types::FieldType; -use leptos_forms_rs::validation::ValidationErrors; -use leptos_forms_rs::{FieldMetadata, Form as LeptosFormTrait}; - -use serde::{Deserialize, Serialize}; - use crate::db::SearchResult; #[cfg(target_arch = "wasm32")] use crate::events::SseEvent; @@ -213,41 +207,12 @@ fn result_row_renderer( columns: RwSignal>, ) -> impl IntoView { view! { - + {SearchResultRow::render_row(row, index, columns)} } } -#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Debug)] -pub struct SearchFormData { - pub query: String, -} - -impl LeptosFormTrait for SearchFormData { - fn default_values() -> Self { - Self { - query: String::new(), - } - } - - fn field_metadata() -> Vec { - vec![FieldMetadata { - name: "query".to_string(), - field_type: FieldType::Text, - ..Default::default() - }] - } - - fn validate(&self) -> Result<(), ValidationErrors> { - Ok(()) - } -} - #[expect( clippy::must_use_candidate, reason = "Leptos #[component] converts this to a view fn; must_use is implicit" @@ -255,8 +220,6 @@ impl LeptosFormTrait for SearchFormData { #[component] pub fn SearchPage() -> impl IntoView { let (query, set_query) = signal(String::new()); - let (_form_handle, _submit, _reset) = - leptos_forms_rs::use_form(SearchFormData::default_values()); let search_action = Action::new(|input: &String| { let input = input.clone(); @@ -333,8 +296,7 @@ pub fn SearchPage() -> impl IntoView { .and_then(Result::ok) .map(|items| { if items.is_empty() { - view! {

"No results found."

} - .into_any() + view! {

"No results found."

}.into_any() } else { let rows: Vec = items .iter() @@ -385,7 +347,8 @@ pub fn LiveFeedPage() -> impl IntoView { on_cleanup(move || stop_cleanup.set(true)); leptos::task::spawn_local(async move { - use futures::{stream, StreamExt}; + use futures::{StreamExt, stream}; + use gloo_net::eventsource::State; use gloo_timers::future::sleep; use leptos::logging; use std::time::Duration; @@ -404,55 +367,70 @@ pub fn LiveFeedPage() -> impl IntoView { match (connected_stream, result_stream, lagged_stream) { (Ok(connected_events), Ok(result_events), Ok(lagged_events)) => { - let mut all_events = stream::select( - stream::select(connected_events, result_events), - lagged_events, - ); - while let Some(result) = all_events.next().await { - if stop.get() { - return; - } - match result { - Ok((_event_type, msg)) => { - let Some(data) = msg.data().as_string() else { - logging::warn!("SSE message had non-string data"); - continue; - }; - match serde_json::from_str::(&data) { - Ok(SseEvent::Connected { .. }) => { - connected.set(true); - } - Ok(SseEvent::SearchResult { - title, - url, - snippet, - }) => { - results.update(|r| { - if r.len() >= 200 { - r.pop_front(); - } - r.push_back(LiveResult { - title, - url, - snippet, - }); - }); - } - Ok(SseEvent::StreamLagged { skipped }) => { + while event_source.state() == State::Connecting && !stop.get() { + sleep(Duration::from_millis(25)).await; + } + + if stop.get() { + return; + } + + if event_source.state() == State::Open { + let data_events = stream::select(result_events, lagged_events); + let mut all_events = + stream::select(connected_events, data_events); + while let Some(result) = all_events.next().await { + if stop.get() { + return; + } + match result { + Ok((_event_type, msg)) => { + let Some(data) = msg.data().as_string() else { logging::warn!( - "SSE stream lagged by {skipped} messages" + "SSE message had non-string data" ); - } - Err(e) => { - logging::warn!("Invalid SSE message: {e:?}"); + continue; + }; + match serde_json::from_str::(&data) { + Ok(SseEvent::Connected { .. }) => { + connected.set(true); + } + Ok(SseEvent::SearchResult { + title, + url, + snippet, + }) => { + results.update(|r| { + if r.len() >= 200 { + r.pop_front(); + } + r.push_back(LiveResult { + title, + url, + snippet, + }); + }); + } + Ok(SseEvent::StreamLagged { skipped }) => { + logging::warn!( + "SSE stream lagged by {skipped} messages" + ); + } + Err(e) => { + logging::warn!( + "Invalid SSE message: {e:?}" + ); + } } } - } - Err(e) => { - logging::warn!("SSE stream error: {e:?}"); - break; + Err(e) => { + logging::warn!("SSE stream error: {e:?}"); + break; + } } } + } else { + logging::warn!("SSE connection closed before opening"); } } _ => logging::error!("Failed to subscribe to named SSE events"), diff --git a/live-search/src/bootstrap.rs b/live-search/src/bootstrap.rs index effab90..c49b84d 100644 --- a/live-search/src/bootstrap.rs +++ b/live-search/src/bootstrap.rs @@ -1,13 +1,13 @@ //! Server bootstrap — initialises all subsystems and starts the HTTP listener. //! //! This module is compiled only under `feature = "ssr"`. It is the single -//! call that sets up tracing, PostgreSQL, migrations, cache, SSE, the +//! call that sets up tracing, `PostgreSQL`, migrations, cache, SSE, the //! `PgListener`, Leptos SSR routes, and graceful shutdown. use std::net::SocketAddr; +use std::sync::Arc; #[cfg(feature = "otel")] use std::sync::OnceLock; -use std::sync::Arc; use anyhow::Context; use axum::http::{StatusCode, Uri}; @@ -79,7 +79,7 @@ async fn health_handler() -> impl IntoResponse { (StatusCode::OK, "ok") } -/// Dependency readiness: verify application state exists and PostgreSQL can +/// Dependency readiness: verify application state exists and `PostgreSQL` can /// answer a trivial query. This is intentionally separate from liveness so an /// orchestrator can stop routing traffic without restart-looping a live process. async fn readiness_handler() -> impl IntoResponse { @@ -206,9 +206,7 @@ pub async fn run() -> anyhow::Result { let router = router.layer(TraceLayer::new_for_http()); #[cfg(feature = "otel")] - let router = router.layer( - axum_tracing_opentelemetry::middleware::OtelAxumLayer::default(), - ); + let router = router.layer(axum_tracing_opentelemetry::middleware::OtelAxumLayer::default()); let router: Router<()> = router.with_state(leptos_options); diff --git a/live-search/src/db.rs b/live-search/src/db.rs index 86211b0..1a70d80 100644 --- a/live-search/src/db.rs +++ b/live-search/src/db.rs @@ -171,7 +171,8 @@ mod server { } const PAYLOAD_LOG_PREVIEW_BYTES: usize = 200; - const RESYNC_BATCH_SIZE: i64 = 100; + const RESYNC_BATCH_SIZE: usize = 100; + const RESYNC_BATCH_SIZE_SQL: i64 = 100; fn broadcast_row( tx: &broadcast::Sender, @@ -183,20 +184,21 @@ mod server { url: Arc::from(row.url.as_str()), snippet: Arc::from(row.snippet.as_str()), }; - match tx.send(event) { - Ok(receivers) => tracing::debug!( + if let Ok(receivers) = tx.send(event) { + tracing::debug!( row_id = %row.id, event_seq = row.event_seq, receivers, context, "forwarded search result event" - ), - Err(_) => tracing::debug!( + ); + } else { + tracing::debug!( row_id = %row.id, event_seq = row.event_seq, context, "search result event had no SSE receivers" - ), + ); } } @@ -270,7 +272,7 @@ mod server { /// /// LISTEN/NOTIFY is not durable delivery. After every successful connect, /// the listener pages through rows with `event_seq > last_seen_seq`, then - /// resumes the live stream. The monotonic sequence avoids UUIDv4 ordering + /// resumes the live stream. The monotonic sequence avoids `UUIDv4` ordering /// bugs, the pagination avoids the old 100-row truncation, and advancing /// the cursor avoids replaying the same rows on consecutive reconnects. #[tracing::instrument(skip_all)] @@ -379,7 +381,7 @@ mod server { LIMIT $2", ) .bind(cursor) - .bind(RESYNC_BATCH_SIZE) + .bind(RESYNC_BATCH_SIZE_SQL) .fetch_all(pool) .await?; @@ -390,7 +392,7 @@ mod server { replayed_any = true; } - if batch_len < RESYNC_BATCH_SIZE as usize { + if batch_len < RESYNC_BATCH_SIZE { break; } } diff --git a/live-search/src/lib.rs b/live-search/src/lib.rs index cb33224..f25a070 100644 --- a/live-search/src/lib.rs +++ b/live-search/src/lib.rs @@ -32,14 +32,14 @@ pub mod shutdown; pub mod otel; // --------------------------------------------------------------------------- -// Hydrate entry point – called by the browser after the WASM module loads. +// Hydrate entry point – called by Leptos HydrationScripts after WASM init. // --------------------------------------------------------------------------- -/// Entry point for WASM hydration. Called by the browser after the module +/// Entry point for WASM hydration. Called by Leptos after the module /// initialises, it hydrates the server-rendered HTML to make the page /// interactive (attaches event handlers, starts reactive system, etc.). #[cfg(feature = "hydrate")] -#[wasm_bindgen::prelude::wasm_bindgen(start)] +#[wasm_bindgen::prelude::wasm_bindgen] pub fn hydrate() { console_error_panic_hook::set_once(); leptos::mount::hydrate_body(app::App); diff --git a/live-search/src/otel.rs b/live-search/src/otel.rs index fc718e7..351b327 100644 --- a/live-search/src/otel.rs +++ b/live-search/src/otel.rs @@ -1,7 +1,7 @@ //! OpenTelemetry initialisation for the `live-search` crate. //! //! Provides [`init_telemetry`] which builds a layered `tracing` subscriber -//! with `EnvFilter`, `fmt`, and an OTel layer, and returns the +//! with `EnvFilter`, `fmt`, and an `OTel` layer, and returns the //! [`SdkTracerProvider`] so the caller can `force_flush` / `shutdown` it //! gracefully. @@ -32,7 +32,6 @@ pub enum TelemetryError { /// /// # Errors /// Returns [`TelemetryError`] when exporter/subscriber/filter setup fails. -#[must_use] pub fn init_telemetry() -> Result { let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT") .unwrap_or_else(|_| "http://127.0.0.1:4318".to_string()); @@ -58,17 +57,18 @@ pub fn init_telemetry() -> Result { opentelemetry_sdk::propagation::TraceContextPropagator::new(), ); - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("info,live_search=debug,tower_http=debug")) - .add_directive("h2=warn".parse().map_err( - |e: tracing_subscriber::filter::ParseError| TelemetryError::Filter(e.to_string()), - )?) - .add_directive("primp_h2=warn".parse().map_err( - |e: tracing_subscriber::filter::ParseError| TelemetryError::Filter(e.to_string()), - )?) - .add_directive("http2=info".parse().map_err( - |e: tracing_subscriber::filter::ParseError| TelemetryError::Filter(e.to_string()), - )?); + let filter = + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,live_search=debug,tower_http=debug")) + .add_directive("h2=warn".parse().map_err( + |e: tracing_subscriber::filter::ParseError| TelemetryError::Filter(e.to_string()), + )?) + .add_directive("primp_h2=warn".parse().map_err( + |e: tracing_subscriber::filter::ParseError| TelemetryError::Filter(e.to_string()), + )?) + .add_directive("http2=info".parse().map_err( + |e: tracing_subscriber::filter::ParseError| TelemetryError::Filter(e.to_string()), + )?); let fmt_layer = tracing_subscriber::fmt::layer().with_target(true).compact(); let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer); diff --git a/live-search/src/shutdown.rs b/live-search/src/shutdown.rs index d1ffde1..8ef7180 100644 --- a/live-search/src/shutdown.rs +++ b/live-search/src/shutdown.rs @@ -1,7 +1,7 @@ //! Graceful shutdown handling for the live-search server. //! //! Shutdown owns the same resources startup created: the cancellation token, -//! background-task JoinSet, PostgreSQL pool, and optional telemetry provider. +//! background-task `JoinSet`, `PostgreSQL` pool, and optional telemetry provider. use std::time::Duration; @@ -15,7 +15,7 @@ use crate::db; /// close the database pool and telemetry provider. /// /// Order matters: background tasks are drained before the pool is closed so the -/// PgListener can observe cancellation and release its connection cleanly. +/// `PgListener` can observe cancellation and release its connection cleanly. /// /// # Panics /// Only panics if OS signal-handler installation fails. @@ -115,9 +115,5 @@ pub async fn wait( } } - if let Some(error) = shutdown_error { - Err(error) - } else { - Ok(()) - } + shutdown_error.map_or_else(|| Ok(()), Err) } diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f3e454b --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +edition = "2024" +style_edition = "2024" diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh index f03d449..95fca07 100644 --- a/scripts/test-e2e.sh +++ b/scripts/test-e2e.sh @@ -5,6 +5,7 @@ # competing set of application servers. set -euo pipefail cd "$(dirname "$0")/.." +WORKSPACE_ROOT="$(pwd)" echo "==> Checking Docker..." if ! docker info >/dev/null 2>&1; then @@ -46,18 +47,27 @@ cargo build --release --locked -p live-search --features ssr cargo build --release --locked -p live-search --lib \ --target wasm32-unknown-unknown --features hydrate -LIVE_SEARCH_PKG_DIR="${LIVE_SEARCH_PKG_DIR:-live-search/target/site/pkg}" -export LIVE_SEARCH_PKG_DIR +# Keep the asset path absolute because Cargo runs each integration-test binary +# with the package directory as its current directory. A relative path that is +# correct here would otherwise be resolved relative to e2e-tests/ at runtime. +LIVE_SEARCH_PKG_DIR="${LIVE_SEARCH_PKG_DIR:-$WORKSPACE_ROOT/target/site/pkg}" mkdir -p "$LIVE_SEARCH_PKG_DIR" +LIVE_SEARCH_PKG_DIR="$(cd "$LIVE_SEARCH_PKG_DIR" && pwd)" +export LIVE_SEARCH_PKG_DIR + +# Match Leptos's configured output-name. Do not export LEPTOS_OUTPUT_NAME here: +# HydrationScripts uses its compile-time presence to decide whether the WASM +# file has wasm-bindgen's `_bg` suffix. The fixture reads output-name/site-pkg-dir +# directly from live-search/Cargo.toml instead. wasm-bindgen \ --target web \ --out-dir "$LIVE_SEARCH_PKG_DIR" \ - --out-name live_search \ + --out-name live-search \ target/wasm32-unknown-unknown/release/live_search.wasm stylance live-search --output-file "$LIVE_SEARCH_PKG_DIR/live-search.css" -test -s "$LIVE_SEARCH_PKG_DIR/live_search.js" -test -s "$LIVE_SEARCH_PKG_DIR/live_search_bg.wasm" +test -s "$LIVE_SEARCH_PKG_DIR/live-search.js" +test -s "$LIVE_SEARCH_PKG_DIR/live-search_bg.wasm" test -s "$LIVE_SEARCH_PKG_DIR/live-search.css" echo "==> Running in-process E2E suite..."