A small Go + Redis backend for interactive widgets embedded in a static blog. The first widget is a like button: anonymous visitors can like a post up to 20 times, and the global count updates live over WebSocket for everyone reading the same post.
This is a learning project. The design is in docs/superpowers/specs/2026-08-14-widgets-api-design.md, and what I learned along the way is in learning.log.
- Pluggable widgets. Widget types implement a common
Widgetinterface and are registered by name.like-widgetis the only one so far. - Anonymous visitors. Each visitor gets an HMAC-signed
anonidcookie (30 days). Tampered cookies are replaced with a fresh ID. - Atomic counting. Each hit runs a Redis Lua script that reads, clamps to the visitor's remaining cap and writes in one step, so concurrent hits can't race past the cap.
- Live updates. Every successful hit is broadcast to all WebSocket clients watching the same widget.
- Abuse protection. Single-origin CORS, an
Origincheck against CSRF, slug validation, and two rate limits (per IP, and per visitor + IP). If Redis is down, rate-limited routes reject requests instead of skipping the check.
- Go 1.26+
- Redis
Create a .env file in the project root (it is git-ignored):
PORT=8080
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=change-me-to-a-long-random-string
ALLOWED_ORIGIN=http://localhost:1313| Variable | Description |
|---|---|
PORT |
Port the server listens on. |
REDIS_URL |
Redis connection URL. |
SECRET_KEY |
Key used to sign visitor IDs. Must be at least 15 characters. |
ALLOWED_ORIGIN |
The one origin (your blog) allowed to call the API, e.g. https://example.com. |
Then start Redis and the server:
redis-server &
go run .The server refuses to start if any variable is missing. Without a .env file it falls back to the process environment.
All widget routes require an Origin header that matches ALLOWED_ORIGIN, and the browser must send credentials (credentials: "include") so the anonid cookie is kept. Because the cookie is Secure and SameSite=None, it only works over HTTPS outside of localhost.
{type} is a registered widget name (like-widget). {slug} must be lowercase letters, digits and single hyphens, at most 100 characters.
Checks the Redis connection.
{ "success": true, "message": "Ok" }Returns the widget's state for the calling visitor.
{
"success": true,
"message": "ok",
"data": { "count": 42, "capped": false, "remaining": 17 }
}count: global count for this widget instancecapped: whether this visitor has used their whole capremaining: hits this visitor has left
Applies a batch of hits. Clients are expected to debounce clicks and send them together.
{ "count": 3 }count must be at least 1. It is clamped to what the visitor has left, so reaching the cap returns 200 with capped: true rather than an error. The response has the same shape as GET.
WebSocket upgrade. After each successful hit on this widget, every connected client receives:
{ "widgetType": "like-widget", "count": 43 }Errors use the same envelope with "success": false:
| Status | When |
|---|---|
400 |
Invalid slug, malformed body, or count < 1 |
403 |
Missing or wrong Origin |
404 |
Unknown widget type |
429 |
Rate limit hit |
500 |
Redis or internal error |
| Limit | Value |
|---|---|
| Likes per visitor per post | 20 |
| Visitor cap lifetime | 30 days |
| Requests per IP | 100 per 60s |
| Requests per visitor + IP | 2 per second |
These are constants in widget.go and rate-limit.go.
.github/workflows/go.yml builds and tests on every push to main, then copies the binary to a VPS over SSH and restarts its systemd service. It needs these repository secrets: VPS_HOST, VPS_USER, VPS_SSH_KEY and PORT (the SSH port).
The server runs as a single process. WebSocket clients are tracked in memory, so running more than one instance would need a shared broadcast channel such as Redis Pub/Sub.