clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat/atlas-next-horizon
atlas / internal / app / app.go
app.go 6.3K
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) serves the native vNext schema, packed typed14// tables and content-addressed blobs. The catalog is composed from whatever is15// installed right now and is never cached; volume content sits under a URL16// carrying the build's stamp, so it may be cached forever and a new build17// arrives at new URLs. app_test.go is the gate on the plane's shape.18//19// The **app plane** is hypermedia: real URLs for volume and world, one POST20// per interaction concern, an SSE stream that says when the library moved, and21// partial responses that name exactly the regions an interaction touched. The22// state-placement rule -- discrete state on the server and in URLs, continuous23// interaction state in the seam -- is documented in docs/app.md and enforced24// by nothing but review.25package app2627import (28 "io/fs"29 "net/http"30 "sort"31 "sync"3233 "github.com/FelineStateMachine/atlas/internal/app/hostenv"34 "github.com/FelineStateMachine/atlas/internal/minting"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 holds what a semantic world costs to stand up. That is work47 // worth doing once per build rather than once per keystroke of a48 // search, and the keys carry the build's stamp, so a new build is a new49 // entry and nothing is ever stale. They are memory, not state: two Apps50 // over one host still answer the same, only slower.51 worlds *worldCache52 semantic *semanticCache53 minter minting.Minter5455 // 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.FS7475 // Minter is the optional native authoring capability. A desktop host76 // supplies it; read-only and headless hosts leave it nil and the authoring77 // controls and routes disappear together.78 Minter minting.Minter79}8081// New wires the application over a host.82func New(env hostenv.Hostenv, opts Options) *App {83 a := &App{84 env: env, static: opts.Static, minter: opts.Minter, events: newHub(), mux: http.NewServeMux(),85 worlds: newWorldCache(), semantic: newSemanticCache(),86 }87 a.routes()88 return a89}9091// ServeHTTP answers one request.92func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { a.mux.ServeHTTP(w, r) }9394// routes is the whole URL surface, in the order docs/app.md lists it. Every95// route is spelled here; nothing registers itself.96func (a *App) routes() {97 // The native vNext data plane.98 a.mux.HandleFunc("GET /data/catalog.json", a.handleCatalog)99 a.mux.HandleFunc("GET /data/v/{slug}/{stamp}/{rest...}", a.handleContent)100101 // The app plane.102 a.mux.HandleFunc("GET /{$}", a.handleHome)103 a.mux.HandleFunc("GET /open", a.handleOpen)104 a.mux.HandleFunc("GET /v/{volume}/{world}", a.handleExplorer)105 a.mux.HandleFunc("GET /fragments/detail/{id}", a.handleDetail)106 a.mux.HandleFunc("POST /session/{concern}", a.handleSession)107 a.mux.HandleFunc("POST /bundles/import", a.handleImport)108 a.mux.HandleFunc("POST /mint/preview", a.handleMintPreview)109 a.mux.HandleFunc("POST /mint", a.handleMint)110 a.mux.HandleFunc("GET /events", a.handleEvents)111 a.mux.HandleFunc("GET /static/{path...}", a.handleStatic)112113 // The application's own chrome: the stylesheet system and the114 // hypermedia runtime, embedded in the binary. It is a separate mount115 // from /static on purpose -- /static is whatever tree a host handed116 // over, and the application is required to work when a host handed over117 // nothing (issue #5 §3.2), which it cannot do if its own chrome lives118 // there.119 a.mux.HandleFunc("GET /assets/{path...}", a.handleAsset)120}121122// library is the volumes serving right now, as one request sees them. It is123// taken once per request: the store swaps whole snapshots, so a request that124// looked up a volume and then opened an entry out of it is reading one125// consistent library even if an import landed in between.126//127// The order is the catalog's -- by title -- because that is the order a reader128// meets volumes in, in the selector and in the catalog alike, and "the first129// volume" should mean the same thing everywhere it is said.130type library struct {131 order []hostenv.Volume132 bySlug map[string]hostenv.Volume133}134135func (a *App) library() library {136 volumes := a.env.Volumes().Volumes()137 // The order is this request's to arrange, so it is arranged over a copy:138 // what a store hands out is the store's.139 held := library{140 order: append(make([]hostenv.Volume, 0, len(volumes)), volumes...),141 bySlug: make(map[string]hostenv.Volume, len(volumes)),142 }143 sort.SliceStable(held.order, func(i, j int) bool {144 return held.order[i].Info().Title < held.order[j].Info().Title145 })146 for _, volume := range volumes {147 held.bySlug[volume.Info().Slug] = volume148 }149 return held150}