1// Package app is the hypermedia application: one http.Handler that serves the2// data plane the seam reads, the server-rendered pages a reader navigates, and3// the partials every interaction answers with.4//5// The handler is pure. It touches no filesystem, builds no path, opens no6// dialog: the library of volumes, the session records, and the file picker all7// arrive through internal/app/hostenv (issue #5 §3.3). That is what lets a8// Wails webview, the headless `atlas serve`, and -- later, at near-zero cost --9// a WASM service worker mount the same code.10//11// Two planes share the mux and answer to different rules.12//13// The **data plane** (/data) is byte-compatible with the implementation this14// one replaces (docs/app.md §2.1; the differences accepted against the15// reference are decision 18). The catalog is composed from whatever is16// installed right now and is never cached; volume content sits under a URL17// carrying the build's stamp, so it may be cached forever and a new build18// arrives at new URLs. app_test.go is the gate on the plane's shape.19//20// The **app plane** is hypermedia: real URLs for volume and world, one POST21// per interaction concern, an SSE stream that says when the library moved, and22// partial responses that name exactly the regions an interaction touched. The23// state-placement rule -- discrete state on the server and in URLs, continuous24// interaction state in the seam -- is documented in docs/app.md and enforced25// by nothing but review.26package app2728import (29 "io/fs"30 "net/http"31 "sort"32 "sync"3334 "github.com/FelineStateMachine/atlas/internal/app/hostenv"35)3637// App is the application. It is an http.Handler and holds no state of its own38// beyond the wiring: everything a request needs it reads through hostenv, so39// two Apps over one host are the same application seen twice.40type App struct {41 env hostenv.Hostenv42 static fs.FS43 events *hub44 mux *http.ServeMux4546 // worlds and texts hold what a world costs to stand up: a payload47 // decoded, every location unpacked, every ring projected. That is work48 // worth doing once per build rather than once per keystroke of a49 // search, and the keys carry the build's stamp, so a new build is a new50 // entry and nothing is ever stale. They are memory, not state: two Apps51 // over one host still answer the same, only slower.52 worlds *worldCache53 texts *textCache5455 // writing serializes the read-modify-write of one volume's record. Two56 // interactions can be in flight at once -- a reader holds a key down, a57 // debounced search overlaps a click, the seam reports a camera while a58 // filter is being answered -- and each one reads the record, changes one59 // field and writes the whole thing back. Without this the later write60 // silently discards whatever the earlier one had just decided, which61 // shows up as a control that answered and then un-answered itself.62 writing sync.Mutex63}6465// Options is what a host tells the application about itself.66type Options struct {67 // Static is the tree served under /static: the seam's built bundle and68 // the stylesheet system. It is an fs.FS rather than a directory because69 // the desktop host embeds it and a dev host reads it from disk, and the70 // handler must not be able to tell which. A nil tree serves no assets,71 // which is what a build without the seam looks like -- the application72 // is required to work without it (issue #5 §3.2).73 Static fs.FS74}7576// New wires the application over a host.77func New(env hostenv.Hostenv, opts Options) *App {78 a := &App{79 env: env, static: opts.Static, events: newHub(), mux: http.NewServeMux(),80 worlds: newWorldCache(), texts: newTextCache(),81 }82 a.routes()83 return a84}8586// ServeHTTP answers one request.87func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { a.mux.ServeHTTP(w, r) }8889// routes is the whole URL surface, in the order docs/app.md lists it. Every90// route is spelled here; nothing registers itself.91func (a *App) routes() {92 // The data plane, byte-compatible with the reference implementation.93 a.mux.HandleFunc("GET /data/catalog.json", a.handleCatalog)94 a.mux.HandleFunc("GET /data/v/{slug}/{stamp}/{rest...}", a.handleContent)9596 // The app plane.97 a.mux.HandleFunc("GET /{$}", a.handleHome)98 a.mux.HandleFunc("GET /open", a.handleOpen)99 a.mux.HandleFunc("GET /v/{volume}/{world}", a.handleExplorer)100 a.mux.HandleFunc("GET /fragments/detail/{id}", a.handleDetail)101 a.mux.HandleFunc("POST /session/{concern}", a.handleSession)102 a.mux.HandleFunc("POST /bundles/import", a.handleImport)103 a.mux.HandleFunc("GET /events", a.handleEvents)104 a.mux.HandleFunc("GET /static/{path...}", a.handleStatic)105106 // The application's own chrome: the stylesheet system and the107 // hypermedia runtime, embedded in the binary. It is a separate mount108 // from /static on purpose -- /static is whatever tree a host handed109 // over, and the application is required to work when a host handed over110 // nothing (issue #5 §3.2), which it cannot do if its own chrome lives111 // there.112 a.mux.HandleFunc("GET /assets/{path...}", a.handleAsset)113}114115// library is the volumes serving right now, as one request sees them. It is116// taken once per request: the store swaps whole snapshots, so a request that117// looked up a volume and then opened an entry out of it is reading one118// consistent library even if an import landed in between.119//120// The order is the catalog's -- by title -- because that is the order a reader121// meets volumes in, in the selector and in the catalog alike, and "the first122// volume" should mean the same thing everywhere it is said.123type library struct {124 order []hostenv.Volume125 bySlug map[string]hostenv.Volume126}127128func (a *App) library() library {129 volumes := a.env.Volumes().Volumes()130 // The order is this request's to arrange, so it is arranged over a copy:131 // what a store hands out is the store's.132 held := library{133 order: append(make([]hostenv.Volume, 0, len(volumes)), volumes...),134 bySlug: make(map[string]hostenv.Volume, len(volumes)),135 }136 sort.SliceStable(held.order, func(i, j int) bool {137 return held.order[i].Manifest().Volume.Title < held.order[j].Manifest().Volume.Title138 })139 for _, volume := range volumes {140 held.bySlug[volume.Manifest().Volume.Slug] = volume141 }142 return held143}