feat/axum-htmx-rewrite #24

Merged
nrupard merged 28 commits from feat/axum-htmx-rewrite into main 2026-05-02 00:19:11 +02:00
Owner
No description provided.
Replace the Dioxus WASM frontend with server-rendered HTML using Axum, Askama, and HTMX. Initial page payload drops from megabytes of WASM to tens of kilobytes of HTML; no WASM compile or hydration. Backend Axum, SQLite databases, session and role model, and WebSocket hub broadcast pattern are preserved. Desktop becomes a Tao+Wry webview wrapper rather than a native Rust UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seventeen sequential tasks on feat/axum-htmx-rewrite branch covering: workspace conversion, Dioxus stripout, Askama base layout, cookie auth, login/register/logout, sidebar, room read/write, WebSocket fragment broadcast, edit/delete, reactions, DMs, search, admin pages, read receipts, desktop webview wrapper, and Docker/justfile cleanup. References docs/superpowers/specs/2026-04-29-axum-htmx-rewrite-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Host has no Rust or Bun toolchain; the wrappers under dev/ run cargo and bun in containers with a persistent named volume for the registry, target dir, and bun cache. dev/server-up runs the server in a detached container with port 8080 published on 127.0.0.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move existing crate into server/ as `lets-chat-server`. Add an empty `desktop/` crate that will host the Tao+Wry webview wrapper. Replace Dioxus dependencies with Axum + Askama + HTMX dependencies in server/Cargo.toml. Vendor htmx, htmx-ws, htmx-response-targets, and idiomorph under server/assets/vendor.

This commit only restructures the tree and dep list. Source files still reference Dioxus and will not compile; subsequent tasks rewrite them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cargo expects the lockfile at the workspace root; the in-tree server/Cargo.lock would have been silently orphaned and never updated. Update .gitignore to match the new server/assets/ asset location with a recursive glob so the generated tailwind-built.css stays ignored regardless of crate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strip out src/components/, src/routes.rs, src/server_fns/, and the WASM build path. Add AppState carrying the three SQLite pools plus the WebSocket Hub. Build a minimal Axum router that serves a placeholder GET / and ServeDir for /assets/. The server now starts via plain `cargo run` without dx.

Tests, db modules, and the existing Hub broadcast types are preserved unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code review of Task 2 surfaced a latent dual-Hub bug: AppState::hub is a fresh Arc<Hub> while ws/hub.rs keeps a module-level static OnceLock<Arc<Hub>>. notify_typing's eviction closure calls get_hub() and would broadcast on the wrong instance once Task 8 wires the WS handler. Add an explicit pre-step to Task 8 instructing the implementer to delete static HUB and refactor notify_typing to take self: &Arc<Self>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce templates/base.html, templates/layout.html, and the welcome page that renders for GET /. Asset URLs are versioned via the package version so vendored htmx scripts and tailwind CSS get a cache-busting query string.

The homepage temporarily uses User::placeholder(); real auth lands in Task 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code review of Task 3 flagged two follow-ups:

1. The header-tuple pattern `Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], body).into_response())` would have to be repeated in every page handler (20+ across the rewrite). Introduce `views::Html(pub String)` plus a `views::html(&template)` free function that renders an Askama template and wraps the body. Handlers now return `Result<Html, AppError>` and end with `Ok(html(&page)?)` (or via `?` propagation).

2. AppState::asset_url was never called; templates already read asset_version directly. Drop it.

Update the plan's Conventions section so future task subagents use the helper instead of askama_axum::Template + page.into_response(). The latter is incompatible with axum 0.8 due to axum_core version skew.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture environment quirks, branch state, plan-wide conventions, and per-task watchpoints so a fresh Claude Code session on a different machine can pick up the rewrite from commit 38fa3ed without re-discovering them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inject the authenticated User into request extensions when the session cookie resolves to a non-banned account. Add AuthUser, OptionalUser, and AdminUser extractors that 303 to /login or return 403 as appropriate. The homepage now requires auth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add GET/POST /login and /register that work both with plain forms (303 + Set-Cookie) and with HTMX (HX-Redirect header). On error, the response targets #form-errors via HTMX hx-target-4*. GET /logout invalidates the session and clears the cookie.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code review of Task 5 surfaced three follow-ups:

1. form_error always rendered LoginPage on the non-HTMX failure path. A user without JS submitting an invalid /register form was shown the login form with the error. Add a FormPage enum so callers select login vs register and the right page is re-rendered.

2. The first-user admin promotion read count_users and conditionally called set_user_role with no transaction wrapping the create + count + update. Two simultaneous registrations could each see count > 1 and neither becomes admin, leaving the install with no admin. Move the count + role update into a single SQL transaction (BEGIN ... SELECT ... UPDATE ... COMMIT).

3. set_user_role failures were silently swallowed. Propagate them as AppError::Internal with a tracing::error! line so the failure is visible in logs.

Verified via smoke test: bad register password (non-HTMX) returns 422 with the Register page (title "Register - lets-chat") rather than the Login page, and existing login/logout flows still work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the sidebar placeholder with a list of rooms visible to the user and a list of DM peers. Both are loaded server-side per request from db::chat. The sidebar is included in every page that extends layout.html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code review of Task 6 flagged that the UserRecord -> User field-by-field copy was duplicated in routes/home.rs (Task 6) and the user_from_record helper in auth.rs (Task 4). Two sites means a missed update silently drops a field rather than failing the build, and routes/home.rs leaks a server-only type into a route module.

Replace with `impl From<UserRecord> for User` on the model, drop the auth.rs helper, and use `record.into()` at both call sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /room/{room_id} renders the room with its recent messages and a placeholder composer. Access is allowed for public rooms, and for private rooms or DMs only when the user is a member (admin sees all non-DM rooms). Message authors are resolved from the auth DB; reactions are rendered as an empty bar for now and will be populated in Task 11. The composer's send-on-submit, typing pings, and WebSocket wiring land in Tasks 8 and 9; for now submitting it does nothing useful.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each ChatEvent variant renders to an Askama template that includes hx-swap-oob attributes. The send loop forwards rendered HTML to the WebSocket; the receive loop handles subscribe and typing frames. The hub remains the source of truth for fan-out; only the wire format changes from JSON events to HTML.

Also unifies the Hub instance: drops the module-level static HUB / get_hub() carryover so notify_typing's eviction task uses the same Arc<Hub> as the rest of the server (state.hub). notify_typing now takes self: &Arc<Self> and clones self into the spawned closure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code review of Task 8 flagged two important issues:

1. routes/ws.rs re-read the session cookie and called db::auth::get_user_by_session even though the inject_user middleware already does that lookup and inserts a User into request extensions. Switch to the existing OptionalUser extractor so each WS upgrade does one session lookup, not two. This also lets the WsUser projection struct go away in favor of the public User type.

2. The subscribe wiring in templates/room/page.html reached into htmx-ext-ws's private `_htmxWebSocket` element property, which is undocumented. Replace with the documented `htmx:wsOpen` event detail's `socketWrapper.send(...)` API, which is part of the public surface.

Also gate ClientFrame::Typing on the per-connection subscribed set so a client cannot leak typing presence into rooms it cannot view (M1 from review).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Persist the message, broadcast a ChatEvent::NewMessage to the hub, and return the cleared composer fragment to HTMX. Subscribed clients receive the rendered message via WebSocket OOB swap; the sender's tab sees the same message land via the same WS broadcast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add GET /messages/:id/edit (returns inline edit form), PATCH /messages/:id (saves edit), and DELETE /messages/:id (soft delete). All three broadcast the corresponding ChatEvent so other tabs and other users see the update immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Toggle reaction via POST /messages/:id/reactions/:emoji. The reaction bar fragment is the canonical view for both the initial render and live updates; the WS broadcast renders the same partial after a reaction event so the count and viewer-reacted state stay in sync across tabs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve the DM room id (creating it if needed) and reuse the room view path with a DM-specific page template. Send/edit/delete/react reuse the existing /room/:id and /messages/:id endpoints because a DM is just a room with two members.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sidebar search input issues debounced HTMX GETs to /search. The handler queries the existing FTS index in db::chat and renders the results fragment into #main. URL is pushed via hx-push-url so back/forward navigates the search state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture the state after Tasks 1-13 (HEAD b830011): branch summary, real db API names that differ from the plan, hub broadcast methods, reaction-rendering pattern, WS subscribe wiring via the public htmx:wsOpen event, and a copy-pasteable implementer prompt for Task 14 (admin pages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds /admin (settings), /admin/users, /admin/invites, /admin/rooms, and /admin/modlog behind the AdminUser extractor. Each page renders the standard sidebar via the shared admin layout and exposes HTMX-driven actions: ban/unban (with a global UserBanned broadcast and per-row OOB swap), invite create/revoke, and room archive (hard delete to match the prior UX). Adds a 5-line db::chat::count_room_members helper for the rooms admin table.

The admin layout lives at templates/admin_layout.html (not templates/admin/layout.html). Naming the layout layout.html inside the admin subdirectory shadows the root layout.html during askama 0.12 path resolution and triggers a self-recursive extends that exhausts the proc-macro process and surfaces as SIGTERM with no diagnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sidebar shows a per-room and per-DM unread count. Loading a room marks the latest message as read for the viewing user and broadcasts ChatEvent::DmRead. The WS fragment renderer filters per-user so the badge only clears on the reader's tabs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The desktop binary opens a single native window pointed at LETS_CHAT_SERVER_URL (default http://localhost:8080). Cookie storage and JS execution are handled by the platform webview; no Rust UI code is involved. Linux GTK env vars are set if unset.

Adds dev/cargo-desktop wrapper that installs pkg-config, libgtk-3-dev, libwebkit2gtk-4.1-dev, libsoup-3.0-dev, and libssl-dev in the rust:1.88-slim-bookworm container before running cargo, since the base image lacks those system libraries that tao/wry build scripts link against. Runtime smoke test (opening the window) is deferred to a host with a desktop dep environment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace justfile with cargo-only recipes that invoke the ./dev/ Docker wrappers (the host has no Rust or Bun); split clippy/test recipes per crate so the slim image is used for the server and the GTK-equipped image is used for the desktop crate.

Rewrite ci-build/Dockerfile.web as a multi-stage cargo-chef + bun + cargo build that produces a non-root runtime image with the binary, server/assets, and server/migrations. Remove ci-build/Dockerfile.desktop-linux and the stale top-level Dockerfile that ran dx serve.

Update compose.yml and compose.dev.yml to build via ci-build/Dockerfile.web with no source mounts; keep the Traefik labels on compose.dev.yml and the published port on compose.yml.

Drop two plan files (2026-04-14-chat-auto-scroll, 2026-04-14-disable-ssr-hydration) that are subsumed or invalidated by the rewrite. Refresh CLAUDE.md and README.md to describe the Axum + Askama + HTMX architecture, the workspace layout under server/ and desktop/, and the ./dev/ wrapper based command surface.

Add the rustfmt and clippy components to the dev/cargo and dev/cargo-desktop wrappers via a shared rustup volume so just fmt / just check work without host Rust. Run cargo fmt --all; the resulting formatting changes are included.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nrupard changed title from WIP: feat/axum-htmx-rewrite to feat/axum-htmx-rewrite 2026-05-02 00:19:04 +02:00
nrupard deleted branch feat/axum-htmx-rewrite 2026-05-02 00:19:11 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!24
No description provided.