crabidy/architecture/web-client.md

8.3 KiB

Web client (cbd-web)

A browser client with the same functionality as the TUI, served by crabidy-server itself so that "open http://server:50051" is the whole install story. Leptos, pure modern CSS, crab orange-red accent, light and dark themes.

Context and problem statement

The TUI covers the owner's desk. Phones, tablets, and guests (see architecture/roles-auth.md — queue-owner / queue-appender roles exist precisely for them) need a client without a terminal. It must not be a second, drifting implementation of the protocol surface: the web client should speak the same gRPC contract as the TUI, feature for feature: library browsing, search terms, queue manipulation, playback control, bookmarks (w), captures (W, with progress and confirmed deletion), and the live update stream.

Assumptions (confirmed or decided)

  • "Exactly the same functionality as the TUI" means the same actions and information, not a terminal emulation: every TUI binding has a clickable equivalent, and the familiar keyboard bindings (j/k/h/l, Tab, %, e, d, w, W, …) also work on desktop browsers.
  • "Local first" is interpreted for what this app is — a remote control for live server state (one audio output, one queue). There is no offline-editing story to sync: a CRDT layer (as in the web_client_example_workspace template, automerge et al.) would model conflicts that cannot occur and add a heavy dependency wall. Local-first here means: client-side rendered, all assets local (no CDN), session state and credentials in the browser, library listings cached in memory like the TUI's, optimistic UI where safe, and graceful reconnect/backoff when the server disappears. This is a deliberate, documented deviation from the example template.
  • The example workspace informs the toolchain (leptos 0.8, trunk, wasm32 target in devenv, workspace layout, lint posture) — not the runtime architecture (SSR/hydration + WebSocket sync). We build a pure CSR app: the server side must stay tonic, not become a leptos SSR host.
  • One port for everything: gRPC (TUI), gRPC-web (browser), and static assets are all served on LISTEN_ADDR (50051). No second listener, no CORS story needed (same origin).

Options considered

Browser transport

  1. gRPC-web with the existing proto — server wraps the existing tonic service in tonic-web (0.14.6, matches our tonic); the browser uses the same generated clients from crabidy-core over tonic-web-wasm-client (0.9.1, tonic ^0.14). Server streaming (GetUpdateStream) is supported. The 24-RPC surface and all types are shared — parity is structural, not aspirational. The auth layer keeps working unchanged: gRPC-web POSTs to the same /crabidy.v1.CrabidyService/… paths, minimum_role sees them identically, and the browser can set the authorization header.
  2. REST + WebSocket bridge — a second API surface to hand-write, secure, and keep in sync. Rejected.

Decision: gRPC-web (1).

Serving the app

  1. Embed the built assets in the server binary (include_dir of cbd-web/dist) behind a cargo feature web-ui, default on (the request), compiled into crabidy-server and thus cbd. The single binary stays self-contained.
  2. Serve from a directory on disk at runtime. Flexible but breaks the single-binary story and invites path confusion. Rejected (can be added later as an override).

Decision: embed (1). tonic 0.14's router is axum-based: Routes::into_axum_router() lets us add plain axum routes for /, /pkg/… and friends next to the gRPC paths. Static assets are served without authentication (the app shell is public; every RPC behind it stays gated) — same posture as any login page.

Building the wasm app

cbd-web is a workspace member built by trunk into cbd-web/dist. Embedding happens through a small crabidy-server/build.rs that copies cbd-web/dist into OUT_DIR when present and otherwise generates a placeholder page ("web UI not built — run devenv shell -- trunk build --release in cbd-web/") so that:

  • plain cargo build never fails and never needs wasm tooling (default-on feature stays harmless),
  • build.rs never invokes cargo-in-cargo (trunk runs cargo; nesting it inside a build script risks target-dir lock deadlocks),
  • rebuilding after a trunk run re-embeds automatically (rerun-if-changed=cbd-web/dist).

devenv gains trunk, wasm-bindgen-cli, binaryen and the wasm32-unknown-unknown rustup target, so the documented build is two commands. The dev loop (trunk serve with a proxy to a running server) is documented in cbd-web/README.md.

crabidy-core on wasm

The generated gRPC client must compile to wasm32-unknown-unknown. crabidy-core trims its tonic dependency to default-features = false, features = ["codegen"] (no transport, no router); native crates keep the full tonic via their own dependency edges, and cargo's per-target feature unification does the rest. Native-only pieces of crabidy-core that do not build on wasm (config loading via dirs, clap plumbing) move behind a cfg(not(target_arch = "wasm32")) gate / target-specific dependencies. The proto types, paths helpers, and client stubs are the wasm surface.

Structure

direction: right
browser: Browser {
  app: "cbd-web (leptos CSR wasm)"
  store: "localStorage:\ncredentials, theme"
  app -> store
}
server: "crabidy-server :50051" {
  axum: axum router
  static: "embedded cbd-web/dist\n(feature web-ui, default on)"
  grpcweb: "tonic-web layer"
  auth: AuthLayer
  rpc: CrabidyService
  axum -> static: "GET /, /pkg/…"
  axum -> grpcweb: "POST /crabidy.v1.…"
  grpcweb -> auth -> rpc
}
tui: cbd-tui
browser.app -> server.axum: "gRPC-web (fetch,\nauthorization header)"
tui -> server.axum: gRPC (HTTP/2)
direction: right
title: cbd-web internals {near: top-center}
rpc: "rpc.rs\ntonic-web-wasm-client,\nsame crabidy-core stubs"
state: "state.rs\nsignals: queue, play\nstate, volume, library\n+ cache, captures"
stream: "stream task\nGetUpdateStream →\nsignals, reconnect backoff"
ui: "components\nLibrary, Queue, NowPlaying,\ndialogs (name, y/N, login)"
keys: "keymap\nTUI-compatible bindings"
rpc -> stream -> state
ui -> rpc: actions
state -> ui: render
keys -> ui

Functional parity map (TUI → web)

TUI Web
library j/k/h/l, Tab, Enter list + click/keys, back button, panes
% create search term "+" affordance & % key → name dialog
e rename, d delete (+capture y/N) item actions & keys → dialogs
w/W bookmark/capture + progress keys/actions → dialog, progress
marks (*), queue/append/replace/insert multi-select & keys
queue ops, x remove, C/c clear, s save buttons & keys
all playback + volume/mute/shuffle/repeat transport bar & keys
skipped tracks red same, via Track.is_skipped
update stream reconnect same, backoff + disconnect banner
auth via config file login form (proactive + forced), localStorage
? help modal ? help overlay listing keys

Styling

Pure hand-written CSS (one style.css, no framework, no CDN): custom properties for the palette, color-scheme: light dark + light-dark()/prefers-color-scheme with a manual override toggle (persisted), CSS nesting, color-mix() for derived tones, grid/flex layout, @media breakpoints for phone layout (library and queue as switchable panes, like Tab in the TUI). Accent color "crab orange-red": --accent: oklch(0.62 0.19 35) (≈ #e14b2a) with hover / active derivations via color-mix. Focus rings and selection bars reuse the accent; skipped tracks and destructive confirms use the existing red semantics.

Risks and open questions

  • tonic-web-wasm-client is a third-party crate; if it ever lags a tonic bump, the pinned pair (tonic 0.14 / 0.9.1) keeps building — upgrade both in lockstep.
  • gRPC-web server streaming holds one HTTP connection per browser tab; fine at household scale.
  • The browser cannot play the audio (output is the server's speakers); a later "play in browser" feature would need a separate audio streaming endpoint — explicitly out of scope.
  • Leptos component logic is hard to unit-test headlessly; logic that matters (path/selection state machines, formatting) lives in plain modules with native #[test]s, components stay thin.