go-websocket
A WebSocket chat service that delivers only to the receiver, never broadcasts
Stack
- Go
- Gin
- gorilla/websocket
- 内嵌静态资源
- Bootstrap 5
Overview
go-websocket is a targeted WebSocket chat service written in Go + Gin: a browser connects with a uid, and the server delivers each message only to the user named in receiver — it never broadcasts. The page template, the static assets and the config templates are all embedded into the binary with //go:embed, so go run . right after cloning is enough — nothing external is needed at runtime.
The point is to be a reading-and-tinkering codebase: the layering, the concurrency model, the config loading and the tests are all small enough to read top to bottom and change directly, with no extra build or runtime dependencies.
Features
- Targeted delivery: a message goes only to the uid in
receiver, never broadcast; if the receiver is offline the sender gets an explicit error back - Unforgeable identity:
senderis filled in by the server from the TCP connection — whatever the client sends is ignored - uid validation up front: letters, digits, underscores and hyphens only, 1–64 characters. An invalid uid is rejected with
400plus a JSON reason before the upgrade handshake - Concurrency-safe: the hub keeps the uid → connection map inside a single goroutine (no mutex on the map), and every write to a connection is funnelled into that connection's one and only write loop
- Heartbeat and timeouts: the server pings on a fixed period and drops the connection when no pong (or no read) arrives, so dead connections do not pile up
- Slow-client protection: every connection has its own outbound queue; once it fills up the peer is considered gone and gets dropped, so one slow client can neither stall the hub nor starve other recipients
- Duplicate uid takeover + graceful shutdown: reconnecting with the same uid replaces the old connection;
Ctrl+C/SIGTERMstops accepting connections first, then closes every WebSocket - Frontend niceties: light / follow-system / dark themes (applied on the first frame), exponential-backoff auto-reconnect, click an online user to pick the receiver, and the uid is stored locally
Quick Start
Requires Go 1.23 or later.
# Run directly (recommended: rerun after every edit, no files left behind)
go run .
# Or build first
go build -o go-websocket . && ./go-websocket
On startup it prints the version, the config source and the accessible URLs:
go-websocket dev (release)
Config source: embedded env.ini.release
Local access: http://127.0.0.1:8090
LAN access: http://192.168.1.10:8090
WebSocket shares the port above, path /ws?uid=<your-uid>
Open two browser windows (or one normal window plus one private window), note each one's uid, type the other's uid, and start chatting — or simply click the other user under "Online users".
Configuration
The config file is INI format. Sources, highest priority first:
- the file given by
-config/ theGWS_CONFIGenvironment variable env.iniin the working directoryconfig/env.iniin the working directory- the embedded
env.ini.<GWS_ENV>(GWS_ENVdefaults torelease)
| Key | Default | Description |
|---|---|---|
env_mode |
release |
debug / release / test; anything else is treated as release |
server.http_port |
8090 |
Listening port |
server.shutdown_timeout |
10s |
Longest time to wait for a graceful shutdown |
websocket.max_message_size |
4096 |
Maximum size of a single message (bytes); larger ones are rejected |
websocket.ping_period |
30s |
How often the server sends a ping; 0 disables the heartbeat |
websocket.pong_wait |
60s |
How long to wait for a pong (also used as the read deadline) |
websocket.send_queue |
64 |
Per-connection outbound queue length; a full queue drops the connection |
websocket.allow_all_origins |
false |
Allow connections from any origin; by default only same-origin is allowed |
The pages and assets under
web/are compiled into the binary by//go:embed. After editing them you must rerungo run .or restart the process — reloading the browser alone will not pick up the change.
API
| Method and path | Parameters | Description |
|---|---|---|
GET / |
- | The chat page |
GET /ws |
uid (required) |
Upgrade to a WebSocket connection; an invalid uid returns 400 |
GET /api/online |
- | Online users, {"count":N,"users":["alice","bob"]} |
WebSocket messages are JSON. Upstream, only these two fields matter — sender is overwritten by the server:
{ "receiver": "bob", "content": "你好" }
Downstream (server → receiver):
{ "type": "chat", "sender": "alice", "receiver": "bob", "content": "你好", "time": "2026-10-01 13:43:43" }
A notice sent to the client that caused it (receiver offline, malformed message, …):
{ "type": "error", "content": "接收人 nobody 不在线", "time": "2026-10-01 13:43:43" }
Implementation Notes
- One goroutine owns the hub map:
Hub.clientsis only ever mutated insiderun(). Everything else goes through channels (register/unregister/deliver/online), so the map needs no lock - One writer per connection: gorilla/websocket does not support concurrent writes to the same connection. Only
writePumpwrites to the socket, and every other goroutine just posts to the queue - No "close the channel" signalling: once
sendis closed, any further post panics — and the poster may be the hub or the read loop, so the ordering cannot be guaranteed.sync.Once+close(done)is used instead, which can never panic a poster - Slow clients are dropped outright: a full
sendqueue means the peer has stopped reading, and piling messages up would only hurt everyone else. The connection is dropped and the sender is told why — a deliberate trade-off - uid is validated before
Upgrade: the failure is a400plus a JSON reason. Reporting an error after the upgrade would only leave a close frame, which tells the frontend nothing senderis overwritten by the server: it is taken from the connection identity only; a client-suppliedsenderis discarded so nobody can impersonate someone else- Read limit:
SetReadLimitcaps the size of a single message — otherwise one client could eat all the memory with an oversized frame - Heartbeat via gorilla's ping/pong: the server pings periodically and refreshes the read deadline in
SetPongHandler, so a dead connection is cleaned up withinpong_waitat worst - Only the read header timeout is set on HTTP: WebSockets are long-lived, so a
ReadTimeout/WriteTimeoutwould cut normal sessions and heartbeats alike - Graceful shutdown takes two steps:
srv.Shutdownstops accepting new connections first, thenh.Close()disconnects the WebSockets still attached — upgraded connections are outsidehttp.Server's bookkeeping - The frontend never builds HTML strings: all user-controlled text is written into the DOM through jQuery's
.text(). Both the uid and the message body are fully controlled by the peer, and pasting them intoinnerHTMLis a ready-made XSS
Known Limitations
- No authentication — anyone who knows a uid can connect, and uids are not reserved (a second connection with the same uid simply takes over). Fine on localhost or a LAN
- No persistence — messages are only forwarded; nothing is replayed when you come back online, and no history is stored
- No HTTPS / WSS — real TLS has to be terminated by a reverse proxy such as Nginx or Caddy
- Single process — the hub lives in memory, so instances cannot see each other's uids. Horizontal scaling would need cross-process delivery such as Redis
- No rooms or broadcast — point-to-point only: no groups, no chat rooms, no offline messages
- The online list is polled — the frontend fetches
/api/onlineevery 5 seconds rather than being notified - The frontend is still jQuery plus a server-side template; there is no frontend/backend split and no asset build pipeline
Links
Source: GitHub
Last updated