Die Demo-Anwendung
WebGlue wächst zusammen mit einer Begleitanwendung: einem kleinen Ticketsystem, das jede Bibliothek nutzt, sobald sie da ist — eine Seite im Browser, ein Socket, der von jeder Änderung erfährt, und dieselben Tickets als Text, DXN oder JSON.
Aufbau
| Teil | Inhalt |
|---|---|
libs/tickets | Die Domäne — Tickets, das Store-Behaviour und sein In-Memory-Store, das Audit-Protokoll — ohne jedes HTTP |
apps/web | Routen, Handler, Middleware und der Supervision-Baum |
apps/web/templates | Die Seite und die Ticket-Zeile, als HTML-Templates |
prx webglue watch | Ein in Praxis geschriebener WebSocket-Client, um Pushes ankommen zu sehen |
Die Domäne
Ein Ticket ist eine Map, und jeder Übergang liefert #(:ok ticket) oder #(:error reason). Ein Ticket wandert von offen zu zugewiesen, zwischen zugewiesen und pausiert hin und her und schließlich zu geschlossen. Jede Änderung landet in einem Protokoll — wer was wann getan hat —, unter dem Verb, das geschah, nicht dem Status, der daraus folgte.
(defn assign [ticket who] :spec [map any -> tuple]
(if (open? ticket)
#(:ok (map/merge ticket {:status :assigned :assignee who}))
#(:error :closed)))Die HTTP-Seite
Die Teile sind die aus den Kernkonzepten: die Routing-Tabelle, die Anwendungsfunktion, der Supervision-Baum, eine Seite aus einem Template, Content-Negotiation beim Lesen, Validierung beim Anlegen und Suchen, ein Socket mit Presence und ein Push bei jeder Änderung. Ein abgelehnter Übergang wird zu 409, eine fehlende Anmeldung zu 401, ein unbekanntes Ticket zu 404, ein unerfüllbares Accept zu 406.
GET / the page: every ticket, and a search form GET /assets/* the files it ships GET /socket/tickets a socket that is told what happens POST /session sign in; DELETE /session sign out POST /tickets raise one, anonymously GET /tickets every ticket, or those ?q= and ?status= find GET /tickets/:id one GET /tickets/:id/trail who did what to it, and when POST /tickets/:id/assign take it POST /tickets/:id/hold wait for the reporter POST /tickets/:id/resume they answered POST /tickets/:id/close done
Eine Sitzung damit
$ prx run --no-halt webglue_demo # WEBGLUE_ADAPTER=wrangler, WEBGLUE_PORT=4100
webglue_demo listening on http://localhost:4000
$ curl -d 'subject=Printer is on fire' localhost:4000/tickets
#1 [open] Printer is on fire
$ curl -X POST localhost:4000/tickets/1/assign
log in first # 401
$ curl -X POST -H 'X-Demo-User: jan' localhost:4000/tickets/1/assign
#1 [assigned] Printer is on fire -> jan
$ curl -H 'Accept: application/dxn' localhost:4000/tickets/1
%{id:1,status::assigned,body:"",subject:"Printer is on fire",assignee:"jan",reporter:nil}
$ curl -H 'Accept: application/json' localhost:4000/tickets/1
{"id":1,"status":"assigned","body":"","subject":"Printer is on fire","assignee":"jan","reporter":null}
$ curl -H 'Accept: image/png' localhost:4000/tickets/1
none of what you accept; this answers text/plain, application/dxn, application/json # 406
$ curl 'localhost:4000/tickets?q=printer'
#1 [assigned] Printer is on fire -> jan
$ curl 'localhost:4000/tickets?status=lost'
status is not one there is # 400
$ curl -X POST -H 'X-Demo-User: jan' localhost:4000/tickets/1/close
#1 [closed] Printer is on fire -> jan
$ curl -X POST -H 'X-Demo-User: jan' localhost:4000/tickets/1/assign
refused: closed # 409
$ curl localhost:4000/tickets/1/trail
2026-10-19T20:01:05.053253Z anonymous raised
2026-10-19T20:01:05.173131Z jan assigned
2026-10-19T20:01:05.663730Z jan closedGET / beantwortet dieselben Tickets als HTML-Seite, gerendert aus den Templates. X-Demo-User ist eine Abkürzung, die die Cookie-Session umgeht, damit curl keinen Cookie-Speicher braucht; die echte Anmeldung ist POST /session mit einem Feld user, und am gesetzten Cookie wird die nächste Anfrage erkannt.
Den Socket beobachten
prx webglue watch ist ein in Praxis geschriebener WebSocket-Client, der Handshake, Maskierung und Framing selbst erledigt. Vor dem Anlegen eines Tickets geöffnet, empfängt er die Begrüßung, einen Presence-Diff und dann beide Umschläge für das neue Ticket — die Daten und die Zeile als Markup-Baum, adressiert an #tickets. Der Socket schreibt DXN-Text, damit ein Mensch ihn lesen kann.
$ prx webglue watch
101 Switching Protocols, and the accept value checks out
<- %{type:"data",topic:"tickets",payload:%{watching:"tickets",whom:["anonymous"]}}
<- %{presence-diff:%{left:%{},joined:%{"anonymous"=>[%{}]}}}
<- %{type:"data",topic:"tickets",payload:%{ticket:%{id:1,status::open,body:"",subject:"Printer is on fire",assignee:nil,reporter:nil},event::raised}}
<- %{type:"html_fragment",element:{:li %{id:"ticket-1",class:"open"} {:span %{class:"id"} "#" "1"} {:span %{class:"subject"} "Printer is on fire"} nil},target:"#tickets"}
-> ping
<- %{type:"data",topic:"tickets",payload:%{pong:3}}Was sie noch nicht tut
- Keine Datenbank — der Store liegt im Speicher, hinter der Nahtstelle zur Datenschicht, und bleibt austauschbar.
- Identität ist ein Name; niemand prüft ihn, und Autorisierung gibt es nicht.
- Lesende Anfragen antworten mit Text, DXN oder JSON; eine Änderung antwortet weiterhin mit einer Textzeile.
- Die Seite hat kein eigenes Skript — gepushte Fragmente werden noch nicht in sie eingesetzt; man beobachtet sie mit einem WebSocket-Client.