3.7 KiB
Guerrilla Queue
Zero-admin self-hosted digital queue. Single URL, QR code on the wall, no login.
Full spec: queue-spec.md | Architecture: ROADMAP.md
Commands
# Run locally (no Docker)
go run . # starts on :8080
APP_URL=http://localhost:8080 go run .
# Build binary
go build -ldflags="-s -w" -o queue .
# Docker — local network
HOST_IP=192.168.1.50 docker compose -f docker-compose.local.yml up -d --build
# Docker — behind Traefik (production)
# Set APP_URL and QUEUE_HOSTNAME in .env first
docker compose up -d --build
File map
| File | Responsibility |
|---|---|
main.go |
HTTP server, routes, gzip middleware, QR handler, embed |
hub.go |
WebSocket hub, client lifecycle, all message dispatch |
queue.go |
QueueState, Entry, every queue mutation + 60 s timers |
static/index.html |
Entire frontend — HTML + CSS + JS inline (~15 KB) |
docker-compose.yml |
Traefik production deployment |
docker-compose.local.yml |
Local network, port-exposed, no Traefik |
Architecture
One goroutine owns everything. hub.run() is the single goroutine that reads from
all channels and calls queue mutations. There is no mutex on QueueState — all access
is serialised through the hub. Do not call queue methods from outside hub.run().
Timer safety. The 60 s removal timer fires by sending an entry ID to hub.timerC
(buffered channel). The hub reads it and calls q.timerRemove(). Never call queue
methods directly from a timer goroutine.
State is ephemeral. The queue lives only in memory. Server restart = empty queue. This is intentional per the spec. Do not add a database.
Frontend is one file. static/index.html contains HTML, CSS, and JS inline.
Keep it that way — one HTTP request, no build step.
WebSocket protocol
Client connects → sends init with stored token → server replies hello + state.
Client messages: init · join · done · mark_done · vote_remove · restore
Server messages: hello · joined · state · error
The secret token is sent to the client exactly once (joined response) and stored in
localStorage. It is never included in state broadcasts.
Key invariants
voteThreshold(n) = min(3, ceil(n/2))— recalculated live, never storedcleanupVotesruns afterremoveEntryso threshold uses the post-removal countpending_removalentries with emptyPendingByafter cleanup are NOT restored (Case A: marker left, let the 60 s timer run). Only entries with remaining-but- insufficient votes are restored (Case B: quorum lost).CompletedSlotsrecords only self-removals (done). Voted-out entries are excluded.avgDurationreturnsniluntil 3+ completions — frontend shows?until then.
Frontend rules
- Vanilla JS only. No framework, no npm, no bundler.
esc(s)must wrap every user-supplied string beforeinnerHTMLinsertion. Currently the only user string isentry.name.- Server-generated values (hex IDs, integers) are safe to interpolate directly.
- Countdown ticks via
setIntervalupdating[data-pa]nodes — no full re-render.
Deployment env vars
| Var | Used by | Notes |
|---|---|---|
APP_URL |
/qr.png handler |
Full public URL in QR code. Falls back to X-Forwarded-Proto + r.Host if unset. Always set in production. |
QUEUE_HOSTNAME |
docker-compose.yml |
Traefik Host() rule. Must match domain in APP_URL. |
HOST_IP |
docker-compose.local.yml |
LAN IP for local-network runs. |
Out of scope (do not add)
Admin panel · push notifications · DB persistence · multiple queues · auth · entry editing after creation · vote retraction · IP-based rate limiting