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_workspacetemplate, 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
- 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 fromcrabidy-coreovertonic-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_rolesees them identically, and the browser can set theauthorizationheader. - REST + WebSocket bridge — a second API surface to hand-write, secure, and keep in sync. Rejected.
Decision: gRPC-web (1).
Serving the app
- Embed the built assets in the server binary (
include_dirofcbd-web/dist) behind a cargo featureweb-ui, default on (the request), compiled intocrabidy-serverand thuscbd. The single binary stays self-contained. - 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 buildnever fails and never needs wasm tooling (default-on feature stays harmless), build.rsnever 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-clientis 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.