The demo app
WebGlue grows together with a companion application: a small ticket system that uses each library as it arrives — a page in the browser, a socket that is told about every change, and the same tickets as text, DXN or JSON.
Layout
| Part | Holds |
|---|---|
libs/tickets | The domain — tickets, the store behaviour and its in-memory store, the audit trail — with no HTTP in it |
apps/web | Routes, handlers, middleware and the supervision tree |
apps/web/templates | The page and the ticket row, as HTML templates |
prx webglue watch | A WebSocket client written in Praxis, to watch pushes arrive |
The domain
A ticket is a map, and each transition returns #(:ok ticket) or #(:error reason). A ticket moves from open to assigned, between assigned and on hold, and finally to closed. Every change is recorded in a trail — who did what, and when — under the verb that happened, not the status that resulted.
(defn assign [ticket who] :spec [map any -> tuple]
(if (open? ticket)
#(:ok (map/merge ticket {:status :assigned :assignee who}))
#(:error :closed)))The HTTP side
The pieces are the ones on Core concepts: the route table, the application function, the supervision tree, a page from a template, content negotiation on reads, validation on raising and searching, a socket with presence, and a push on every change. A refused transition becomes a 409, a missing login a 401, an unknown ticket a 404, an unreadable Accept a 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
A session with it
$ 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 / answers the same tickets as an HTML page, rendered from the templates. X-Demo-User is a convenience that skips the cookie session so curl needs no cookie jar; the real sign-in is POST /session with a user field, and the cookie it sets is what the next request is known by.
Watching the socket
prx webglue watch is a WebSocket client written in Praxis that does the handshake, masking and framing itself. Opened before a ticket is raised, it receives the greeting, a presence diff, and then both envelopes for the new ticket — the data, and the row as a markup tree addressed to #tickets. The socket writes DXN text so a person can read it.
$ 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}}What it does not do yet
- No database — the store is in memory, behind the data-layer seam, and stays swappable.
- Identity is a name; nothing checks it, and there is no authorization.
- Reads answer text, DXN or JSON; a change still answers a line of text.
- The page has no script of its own — pushed fragments are not applied to it yet; they are watched with a WebSocket client.