91 lines
3.7 KiB
Markdown
91 lines
3.7 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
# 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 stored
|
|
- `cleanupVotes` runs *after* `removeEntry` so threshold uses the post-removal count
|
|
- `pending_removal` entries with empty `PendingBy` after 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).
|
|
- `CompletedSlots` records only self-removals (`done`). Voted-out entries are excluded.
|
|
- `avgDuration` returns `nil` until 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 before `innerHTML` insertion.
|
|
Currently the only user string is `entry.name`.
|
|
- Server-generated values (hex IDs, integers) are safe to interpolate directly.
|
|
- Countdown ticks via `setInterval` updating `[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
|