joetjen.net
EN DE
Web framework

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.

In development · v0.8

Layout

PartHolds
libs/ticketsThe domain — tickets, the store behaviour and its in-memory store, the audit trail — with no HTTP in it
apps/webRoutes, handlers, middleware and the supervision tree
apps/web/templatesThe page and the ticket row, as HTML templates
prx webglue watchA 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.

libs/tickets/src/webglue/demo/ticket.prax
(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 closed

GET / 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.