One Go binary serves a pre-rendered site. No Node, no bundler, no node_modules:
htmx and the SSE extension are vendored files, Markdown and code highlighting
run at build time (goldmark + chroma), and the server only writes files.
Implements docs/phase-2-static-site.md:
2.1 service skeleton cmd/build (content -> dist), cmd/server (dist -> HTTP),
env-configured, 127.0.0.1:8096, /healthz with separate
static / ai / search readiness, --health-check probe,
deploy/compose.yml + deploy/Dockerfile at 120m
2.2 rendering templ components (Layout, Nav, PillarCard, Stage,
EvidenceTable, CodeBlock, Timeline, Footer);
`make generate` is a build step and is byte-stable
2.3 navigation six-area rail, <details> disclosure under 900px,
breadcrumbs, aria-current, L1/L2/L3 with artefact pages
2.4 stage panel internal/stage owns the contract; RouteDriver (fragment
swap) and SSEDriver (event stream) differ only in the
Binding, never in layout. fragment ids live in the
contract so route files, links and streamed frames agree
2.5 search SQLite FTS5 over titles and body, rebuilt by the build;
one handler returns a page without HTMX, a fragment with
it. Visitors' FTS operators are never honoured
2.6 quality floor print stylesheet, JS-off navigation, focus states,
semantic landmarks, dark default; security headers
Definition of Done evidence (see issue AUD-142):
A1 all pages 200 in a network namespace with no external route
one binary serves the site; make dist writes 45 pages, 86 fragments, 40 docs
templ generate is deterministic (md5 identical across runs)
6 L1 + 6 L2 pages, artefact pages reachable in <= 3 clicks
FTS5 returns sensible hits for zigbee / signage / routing (tested)
Lighthouse 100 performance and 100 accessibility, mobile and desktop
first byte well inside 100ms over loopback (test)
panel renders from a route driver and re-points at the stream untouched
print legible: /edge/ renders to a 3-page PDF with the print stylesheet
Also fixes found while finishing the build:
- reject "." / ".." path segments before ServeMux redirects them into a file
outside the asset directory (/static/../build.json now 404, was 307->200)
- make test is the real gate: go test ./... plus the validator self-test
189 lines
5.2 KiB
Go
189 lines
5.2 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
|
|
)
|
|
|
|
// StreamEvent is the SSE event the panel listens for. The binding rendered by
|
|
// stage.SSEDriver uses the same name, so a client needs no configuration.
|
|
const StreamEvent = "stage"
|
|
|
|
// maxStreamKeys caps how many fragments one connection may ask for, so a
|
|
// crafted query cannot turn the demonstration into a long-lived loop.
|
|
const maxStreamKeys = 24
|
|
|
|
// handleStream points the stage panel at an event stream.
|
|
//
|
|
// This is the Phase 2 proof of the §2.4 contract: the panel markup does not
|
|
// change, only the binding does. The frames come from the fragments the build
|
|
// already rendered, so nothing calls a model and the site still works with
|
|
// every AI service stopped. Phase 5 replaces the source behind the same
|
|
// interface (stage.Source) and this handler does not change.
|
|
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "streaming is not supported by this server", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if s.manifest == nil || len(s.manifest.Entries) == 0 {
|
|
msg := "the stage manifest is not part of this build"
|
|
if s.manifestErr != nil {
|
|
s.log.Warn("stream unavailable", "error", s.manifestErr)
|
|
}
|
|
http.Error(w, msg, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
keys := s.streamKeys(r.URL.Query())
|
|
if len(keys) == 0 {
|
|
keys = s.defaultStreamKeys()
|
|
}
|
|
if len(keys) == 0 {
|
|
http.Error(w, "no fragments to stream", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
h := w.Header()
|
|
h.Set("Content-Type", "text/event-stream; charset=utf-8")
|
|
h.Set("Cache-Control", "no-store")
|
|
h.Set("Connection", "keep-alive")
|
|
h.Set("X-Accel-Buffering", "no")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
// A client that reconnects gets a short pause rather than an immediate retry.
|
|
if _, err := io.WriteString(w, "retry: 2000\n\n"); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
|
|
ctx := r.Context()
|
|
source := s.newStreamSource(ctx)
|
|
err := source.Stream(ctx, keys, func(f stage.Fragment) error {
|
|
var buf bytes.Buffer
|
|
if f.Rendered {
|
|
// The build already rendered the whole article; send those bytes.
|
|
if err := f.Body.Render(ctx, &buf); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
// A structured source (Phase 5) hands over parts, so the panel
|
|
// renders them here with the same component the route driver uses.
|
|
if err := templates.FragmentView(f).Render(ctx, &buf); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := writeSSE(w, StreamEvent, buf.String()); err != nil {
|
|
return err
|
|
}
|
|
flusher.Flush()
|
|
return nil
|
|
})
|
|
if err != nil && ctx.Err() == nil {
|
|
// Headers are already sent, so there is no status to change. Log it and
|
|
// let the panel keep the last fragment it received.
|
|
s.log.Warn("stage stream ended early", "error", err)
|
|
}
|
|
}
|
|
|
|
// streamKeys reads the fragment ids a caller asked for. Both `?k=a&k=b` and
|
|
// `?k=a,b` work, because both are natural to write by hand.
|
|
func (s *Server) streamKeys(q url.Values) []string {
|
|
var keys []string
|
|
for _, raw := range q["k"] {
|
|
for _, part := range strings.Split(raw, ",") {
|
|
id := strings.TrimSpace(part)
|
|
if id != "" && validFragmentID(id) {
|
|
keys = append(keys, id)
|
|
}
|
|
if len(keys) >= maxStreamKeys {
|
|
return keys
|
|
}
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// defaultStreamKeys is the demonstration stream: the six area landing
|
|
// fragments, in navigation order. The build writes those first, so the manifest
|
|
// alone is enough — the server never reads the content tree.
|
|
func (s *Server) defaultStreamKeys() []string {
|
|
if s.manifest == nil {
|
|
return nil
|
|
}
|
|
keys := make([]string, 0, 6)
|
|
for _, e := range s.manifest.Entries {
|
|
if !strings.HasPrefix(e.ID, "doc-") {
|
|
continue
|
|
}
|
|
keys = append(keys, e.ID)
|
|
if len(keys) == 6 {
|
|
break
|
|
}
|
|
}
|
|
if len(keys) > 0 {
|
|
return keys
|
|
}
|
|
for _, e := range s.manifest.Entries {
|
|
keys = append(keys, e.ID)
|
|
if len(keys) == 6 {
|
|
break
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// newStreamSource wires the manifest to the files on disk and paces the frames.
|
|
func (s *Server) newStreamSource(ctx context.Context) *stage.ManifestSource {
|
|
read := func(e stage.Entry) (string, error) {
|
|
file := filepath.Join(s.dist, filepath.FromSlash(e.File))
|
|
if !within(s.dist, file) {
|
|
return "", fmt.Errorf("fragment %s is outside the build directory", e.ID)
|
|
}
|
|
raw, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fragment %s: %w", e.ID, err)
|
|
}
|
|
return string(raw), nil
|
|
}
|
|
delay := s.cfg.StreamDelay
|
|
frame := func(i int) {
|
|
if i == 0 || delay <= 0 {
|
|
return
|
|
}
|
|
select {
|
|
case <-time.After(delay):
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
return stage.NewManifestSource(s.manifest, read, frame)
|
|
}
|
|
|
|
// writeSSE writes one event. A payload with newlines is split across data
|
|
// lines, which is what the server-sent events format expects.
|
|
func writeSSE(w io.Writer, event, data string) error {
|
|
var b strings.Builder
|
|
b.WriteString("event: ")
|
|
b.WriteString(event)
|
|
b.WriteByte('\n')
|
|
for _, line := range strings.Split(data, "\n") {
|
|
b.WriteString("data: ")
|
|
b.WriteString(line)
|
|
b.WriteByte('\n')
|
|
}
|
|
b.WriteByte('\n')
|
|
_, err := io.WriteString(w, b.String())
|
|
return err
|
|
}
|