Status:🤷 UNKNOWN
Date:📅 unknown
Decision Makers:unknown
ADR-0006: Web stack — net/http (Go 1.22 routing) + html/template + HTMX, no SPA
- Status: Accepted
- Date: 2026-06-27
- Builds on: 📝 ADR-0001 (single SQLite store the UI reads)
Context
msgbrowse needs a UI to browse conversations, run live keyword search, scroll a transcript, and view a media gallery — over a single-user, local-only archive. The data already lives in SQLite (FTS5 search, source-tagged rows). The question is what to put in front of it.
The constraints push hard away from a JavaScript SPA:
- Strict CSP, no CDN. The security posture (📝 ADR-0010)
sets
Content-Security-Policy: default-src 'none'; script-src 'self'— every script must be same-origin and self-hosted. A framework loaded from a CDN, or a bundler emittingeval/inline scripts, is incompatible with that header. - No Node at runtime. The runtime is a single Go binary; we do not want an
npm toolchain in the container or a
node_modulesin the request path. - Untrusted content. Message bodies are attacker-controlled (a crafted archive). Auto-escaping must be the default, not opt-in.
- Scale is tiny. One user, one machine. There is no interactivity that warrants client-side state management.
Decision
Server-render everything with the standard library, sprinkle HTMX for partial updates, and ship no SPA and no runtime npm.
- Routing:
net/httpwith Go 1.22 method+pattern mux.internal/web/server.goregisters method-qualified patterns (GET /c/{id},GET /c/{id}/messages,GET /c/{id}/at/{mid},GET /search/results,GET /media/{id}/{path...}), reading path values withr.PathValue. No third-party router. - Rendering:
html/template. Templates arego:embed-ed (//go:embed templates/*.html) and parsed once at startup. All message content flows through the template engine's contextual auto-escaping;renderBody(render.go) additionally HTML-escapes every text run before emittingtemplate.HTML, drops image/media markdown, and linkifies bare URLs withrel="noopener noreferrer nofollow". Search snippets are escaped before highlight<mark>sentinels are substituted (highlightSnippet). - Interactivity: HTMX, progressive-enhancement only. Forms and links work
without JavaScript; HTMX upgrades them. Live search is a plain
<form action="/search" method="get">that HTMX turns into partial swaps (hx-get="/search/results" hx-target="#search-results" hx-trigger="input changed delay:300ms, change"). Infinite scroll on the transcript useshx-trigger="revealed"on a sentinel that swaps in the next page (message_listpartial intemplates/partials.html). - Partials are just named templates. The same
message_list/search_resultsblocks render both the full page and the HTMX fragment, so there is one source of truth per view. - HTMX is vendored and pinned by SHA-256.
static/htmx.min.jsis committed (script-src 'self'means it is the only script that can ever run), withstatic/HTMX-VERSION.mdrecording version2.0.4, the byte size, the upstream URL, and the SHA-256 (e209dda5…fb447) plus an update procedure that requires re-verifying the hash against a second source.
Why these choices
- HTMX over a JS SPA: the only dynamic surfaces are debounced search and
reveal-triggered pagination — both are a few
hx-*attributes against endpoints that return HTML the server already knows how to render. An SPA would add a build step, a client state layer, a JSON API, and CSP friction, for zero capability we lack. html/templateauto-escaping over manual sanitization: message bodies are untrusted; contextual auto-escaping is the safe default and means a missed escape is the exception that stands out (eachtemplate.HTMLsite inrender.gois deliberate and escapes its own inputs).- stdlib router over chi/gorilla: Go 1.22 patterns cover method routing,
wildcards, and
{path...}rest-matching — the whole route table fits inroutes()with no dependency. - Vendoring + SHA pin over a CDN
<script>: the strict CSP forbids a CDN anyway, and pinning by hash makes a supply-chain swap of htmx a visible, reviewable change rather than a silent upstream fetch.
Consequences
Positive
- One Go binary serves the whole UI; no runtime Node, no bundler, no JSON API to keep in sync with the templates.
- The strict CSP holds because everything is same-origin and self-hosted (one vendored script, one stylesheet, inline SVG icons).
- Auto-escaping makes XSS the hard-to-hit path: untrusted bodies are escaped by
default and re-escaped in
renderBody. - Search and pagination degrade gracefully — the form and links still work with JavaScript disabled.
Negative
- Rich client interactions (drag, offline, optimistic UI) would be awkward; if the UI ever needs them, this is the wrong substrate.
- HTMX must be manually re-vendored and re-hashed on upgrade — there is no
package manager doing it for us (the
HTMX-VERSION.mdprocedure exists for exactly this). - Partial endpoints (
/search/results,/c/{id}/messages) return HTML fragments, so they are coupled to the templates rather than being a reusable JSON contract.
Operational
- Templates and static assets are embedded, so the binary is self-contained — no asset paths to mount at runtime.
- The
revealedinfinite-scroll trigger is the most regression-prone surface; the htmx update checklist calls it out for manual exercise after a bump.
Alternatives considered
- A JS SPA (React/Svelte) over a JSON API. Rejected: needs npm at build time,
fights the
script-src 'self'/no-CDN CSP, and adds a client state layer for a single-user app whose only dynamism is search + scroll. - A third-party Go router (chi, gorilla/mux). Rejected: Go 1.22 patterns
already do method routing and
{path...}matching; a dependency buys nothing. text/template+ manual escaping. Rejected: gives up contextual auto-escaping on untrusted message content — the wrong default for this threat model.
References
internal/web/server.go(routes, embed, CSP middleware)internal/web/render.go(renderBody,highlightSnippet, escaping)internal/web/templates/(partials.htmlmessage_list,search.html)internal/web/static/HTMX-VERSION.md(pin + SHA-256)- 📝 ADR-0010: Security & privacy posture
- htmx 2.0.4