Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

api-server

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.

Features

  • Pluggable widgets. Widget types implement a common Widget interface and are registered by name. like-widget is the only one so far.
  • Anonymous visitors. Each visitor gets an HMAC-signed anonid cookie (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 Origin check 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.

Requirements

  • Go 1.26+
  • Redis

Running locally

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.

API

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.

GET /health

Checks the Redis connection.

{ "success": true, "message": "Ok" }

GET /widgets/{type}/{slug}

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 instance
  • capped: whether this visitor has used their whole cap
  • remaining: hits this visitor has left

POST /widgets/{type}/{slug}/hit

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.

GET /widgets/{type}/{slug}/ws

WebSocket upgrade. After each successful hit on this widget, every connected client receives:

{ "widgetType": "like-widget", "count": 43 }

Errors

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

Limits

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.

Deployment

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

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages