Skip to content

emilhauk/msg

Repository files navigation

msg.

A minimal public chat-room application. Real-time messages delivered via Server-Sent Events, OAuth login (GitHub, Google, Discord), and link previews — no JavaScript framework required.

Stack

  • Go — HTTP server, business logic
  • HTMX — reactive UI without a JS framework
  • Redis — sole data store (messages, sessions, pub/sub)
  • SSE — real-time message fan-out

Running locally

Prerequisites

  • Go 1.25+
  • Docker (for Redis)
  • OAuth credentials for at least one provider (GitHub, Google, or Discord)

1. Start Redis

docker run -d -p 6379:6379 redis:alpine

Or use the included Compose file, which also spins up RedisInsight:

docker compose up redis -d

2. Configure environment

cp .env.example .env

Fill in .env:

Variable Description
REDIS_URL Redis connection URL, e.g. redis://localhost:6379
SESSION_SECRET Random 32-byte hex string — openssl rand -hex 32
BASE_URL Public-facing base URL, e.g. http://localhost:8080
PORT HTTP port (default 8080)
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET GitHub OAuth app
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET Google OAuth credentials
DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET Discord application
OPEN_REGISTRATION true = anyone may log in; false (default) = allowlist only
ALLOW_LIST Comma-separated emails allowed when OPEN_REGISTRATION=false
MICROLINK_API_KEY Optional — only needed above the free tier
S3_ENDPOINT S3-compatible endpoint, e.g. https://s3.example.com — omit to disable media uploads
S3_BUCKET Bucket name for uploaded media
S3_REGION Region string (MinIO accepts any value, e.g. us-east-1)
S3_ACCESS_KEY_ID S3 access key ID
S3_SECRET_ACCESS_KEY S3 secret access key

OAuth callback URLs to register with each provider:

http://localhost:8080/auth/github/callback
http://localhost:8080/auth/google/callback
http://localhost:8080/auth/discord/callback

3. Configure media uploads (optional)

Media uploads (paste to send images/video) require an S3-compatible object store such as MinIO. The steps below use mc (the MinIO CLI client) and assume:

  • MinIO is reachable at https://s3.example.com
  • The app is at https://msg.example.com

Install mc:

# macOS
brew install minio/stable/mc

# Linux
curl https://dl.min.io/client/mc/release/linux-amd64/mc \
  -o /usr/local/bin/mc && chmod +x /usr/local/bin/mc

On Arch Linux the package is mcli (naming conflict with Midnight Commander). Set MC_CONFIG_DIR="${XDG_CONFIG_HOME}/mc" to keep config out of ~/.mc.

Register your MinIO instance as an alias:

mc alias set myminio https://s3.example.com ACCESS_KEY_ID SECRET_ACCESS_KEY

Create the bucket and make it publicly readable:

mc mb myminio/msg-media
mc anonymous set download myminio/msg-media

Apply the CORS policy

For MinIO; this can be done by setting the env var:

service:
  storage:
    environment:
      MINIO_API_CORS_ALLOWED_ORIGINS: "http://localhost:8080"

Add to .env:

S3_ENDPOINT=https://s3.example.com
S3_BUCKET=msg-media
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=your_access_key_id
S3_SECRET_ACCESS_KEY=your_secret_access_key

S3_REGION can be any non-empty string; MinIO ignores it. If S3_ENDPOINT is not set the upload route is not registered and the paste-to-upload handler silently does nothing.

4. Run

export $(grep -v '^#' .env | xargs)
go run ./...

Open http://localhost:8080.

Live reload (optional)

The project ships with an Air config for hot-reload during development:

go install github.com/air-verse/air@latest
air -c .air.toml

Or start everything via Docker Compose (uses Air inside the container):

docker compose up

Testing

Unit and integration tests

No external services required — tests use an in-process Redis (miniredis).

go test ./... -race -timeout 60s -count=1 -short

JS linting

npm run lint

E2E browser tests

Requires headless Chromium (go-rod will find it automatically if installed).

go test ./internal/browser/... -v -timeout 120s

To run with a visible browser window — useful for debugging or watching tests execute:

HEADLESS=false go test ./internal/browser/... -v -timeout 120s

To run a single test:

HEADLESS=false go test ./internal/browser/... -v -timeout 120s -run TestThemeToggle_DarkOS

Run everything

make test

This runs lint, unit/integration tests, and E2E browser tests in sequence.

Container image

Production images are published to the GitHub Container Registry on every push:

ghcr.io/emilhauk/msg:<branch>
ghcr.io/emilhauk/msg:<short-sha>

Pull and run:

docker run -p 8080:8080 --env-file .env \
  -e REDIS_URL=redis://your-redis:6379 \
  ghcr.io/emilhauk/msg:main

Project structure

.
├── main.go                  # Entry point: routes, server startup, room seeding
├── internal/
│   ├── auth/                # OAuth flow and signed-cookie sessions
│   ├── handler/             # HTTP handlers (rooms, messages, SSE)
│   ├── middleware/          # Session validation
│   ├── model/               # Shared structs
│   ├── redis/               # Typed Redis helpers
│   └── tmpl/                # Template rendering
└── web/
    ├── templates/           # HTML templates (base layout, room, message partials)
    └── static/              # CSS

Reverse proxy

msg uses Server-Sent Events for real-time delivery. SSE connections are long-lived and streaming, which requires specific reverse proxy configuration — the defaults are typically wrong.

Without correct configuration you may see ERR_HTTP2_PROTOCOL_ERROR in the browser console and messages silently failing to appear (until the connection recovers on reconnect).

nginx

The SSE endpoint (/rooms/*/events) needs its own location block with buffering disabled and an extended read timeout. The upstream connection must use HTTP/1.1 (nginx defaults to HTTP/1.0 for proxied requests, which does not support keep-alive streaming).

server {
  listen 443 ssl;
  http2  on;
  # ... ssl_certificate, server_name, etc.

  # SSE endpoint — disable buffering, extend timeout, use HTTP/1.1 upstream
  location ~ ^/rooms/[^/]+/events$ {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;

    proxy_http_version 1.1;
    proxy_set_header Connection '';

    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 3600s;
    chunked_transfer_encoding on;
  }

  location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
  }
}

For other reverse proxies (Caddy, Traefik, HAProxy, etc.) consult their documentation for SSE or long-lived streaming connections — the same principles apply: disable response buffering and set a long (or unlimited) upstream read timeout.

Known limitations

  • SSE reconnect recovery — on reconnect, the client fetches the 50 newest messages and merges them into the view. If more than 50 messages were sent during the gap, only the 50 most recent are restored; earlier messages in the gap are not surfaced automatically but remain accessible via scrollback.

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors