go-websocket
A WebSocket chat service that delivers only to the receiver, never broadcasts
Stack
- Go
- Gin
- gorilla/websocket
- 内嵌静态资源
- Bootstrap 5
A small 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: letters, digits, underscores and hyphens only, 1–64 characters. An
invalid uid is rejected with
400plus a JSON reason before the upgrade handshake, instead of leaving the frontend with a handshake that fails for no stated reason. - 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, so a WebSocket is never written concurrently.
- 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 stop other people from receiving their messages.
- Duplicate uid takeover: connecting again with the same uid replaces the old connection, so messages never end up on a connection nobody is watching.
- Graceful shutdown:
Ctrl+C/SIGTERMstops accepting connections first, then closes every WebSocket before exiting. - Online list:
GET /api/onlinereturns the uids currently online (sorted). - Frontend niceties: light / follow-system / dark themes (applied on the first frame, so no dark-mode flash), exponential-backoff auto-reconnect, click an online user to pick them as the receiver, and the uid is stored locally (a refresh keeps your identity).
- Location-aware address: the frontend builds
ws://orwss://fromlocation, so changing the port or host, or moving to https, needs no code change.
Project Layout
.
├── main.go Entry point: wires config, router, graceful shutdown
├── config/ Config loading (embedded env.ini.<env> templates + external env.ini override)
├── common/ Utilities and protocol: uid validation, message codec, log clipping
├── handle/ The implementation: hub (hub.go) + WebSocket I/O (handle.go)
├── router/ Route assembly: pages, static assets, WebSocket, API group
├── test/ In-process router tests + end-to-end WebSocket tests
├── web/
│ ├── view/index.html Page template (embedded)
│ └── static/ Bootstrap / jQuery / frontend scripts / icons (embedded)
└── go.mod / go.sum Dependencies: gin, gorilla/websocket, ini
Quick Start
Requires Go 1.23 or later. If you would rather not install Go, grab the archive for your
platform from Releases:
.tar.gz for Linux / macOS, .zip for Windows. Unpacking gives a ready-to-run directory
(the executable, a start launcher, env.ini, LICENSE) — double-click start.bat on
Windows, run ./start.sh elsewhere, or just run the executable directly. The page and the
static assets are embedded in the executable.
# 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:
2026-10-01 13:43:43 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" on the left.
Command-line flags
| Flag | Default | Description |
|---|---|---|
-config |
empty | Path to an external config file (ini); when empty, the priority list below is searched |
-version |
false |
Print the version and exit |
Configuration
The config file is INI (config/env.ini.<env> are the templates kept in the repo):
| Key | Default | Description |
|---|---|---|
env_mode |
release |
Run mode: debug / release / test; anything else is treated as release |
server.protocol |
http |
Only used to build the address in the startup log; it does not change how the server listens |
server.http_port |
8090 |
Listening port |
server.shutdown_timeout |
10s |
Longest time to wait for a graceful shutdown |
websocket.read_buffer_size |
1024 |
Read buffer size per connection (bytes) |
websocket.write_buffer_size |
1024 |
Write buffer size per connection (bytes) |
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); 0 means no limit |
websocket.write_wait |
10s |
Write timeout; 0 means no limit |
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 (cross-site); by default only same-origin is allowed |
Config sources, highest priority first:
- the file given by
-config/ theGWS_CONFIGenvironment variable (it must exist); env.iniin the working directory;config/env.iniin the working directory;- the embedded
env.ini.<GWS_ENV>(GWS_ENVdefaults torelease); - the embedded
env.ini.release.
Four ways to change the config locally — pick whichever you like:
- ① Copy a template: copy it to
config/env.ini; that file is already in.gitignore; - ② Point
-configat it: aim straight at a template file and leave the repo untouched; - ③ Pick an embedded template with
GWS_ENV:debug/local/release/test; - ④ Use the
GWS_CONFIGenvironment variable: equivalent to-config.
Copy-Item config\env.ini.local config\env.ini # ①
go run . -config config\env.ini.local # ②
$env:GWS_ENV = 'local'; go run . # ③
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 (JSON):
Upstream (client → server) — 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. Each connection has its own
sendqueue, onlywritePumpwrites 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; the worst case is one wasted post. - 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. - Failed delivery talks back: an offline receiver, or a full send queue, both produce a
type=errormessage to the sender. - uid is validated up front: validation happens before
Upgrade, so 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. - Same-origin check: only same-origin connections are allowed by default (the page is
served by this very service, so same-origin is a given). Clients without an
Originheader are let through — that is a debugging tool, not a browser cross-site scenario. Setallow_all_originsto loosen this. Recoveryis always on:gin.New()ships with neither Logger nor Recovery, and any panic inside a handler would take the process down.Recoveryis attached explicitly, withLoggeradded in non-release modes.- Four embedded env templates: this avoids the classic "there is no
env.iniin the repo, so a fresh clone fails withno matching files found" problem; an externalenv.inican still override them. - Config access has defaults and never panics: an invalid
env_modefalls back torelease(otherwisegin.SetModepanics outright), andGetInt/GetBool/GetDurationreturn the default when a key is missing or unparsable. - 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, so relying onShutdownalone would miss them. - 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. - The frontend address follows the page:
ws(s)://is built fromlocation, so a different port, a different host or https all work without touching the code. - Dependencies are injected explicitly:
handle.New(cfg)takes a config struct, so a test can pass an aggressive "heartbeat: one hour" setup without editing a config file;router.R(...)takes anfs.FS, so tests just useos.DirFS("..").
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:
server.protocolonly affects the address printed in the startup log; 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.
Development
go vet ./... # static checks
go test ./... # unit tests + in-process router tests + end-to-end WebSocket tests
Test coverage: the index page and static assets, /api/online, 404s, targeted message
forwarding, sender being unforgeable, the offline-receiver notice, three kinds of
malformed upstream messages, invalid uids being rejected with 400, duplicate-uid takeover,
and reconnecting after a disconnect.
The cases in
test/ws_test.goneed a real listening port: a WebSocket handshake requires Hijack, whichhttptest.NewRecordercannot do. They usehttptest.NewServer, so no fixed port is occupied.Running the race detector needs cgo (and a local gcc):
CGO_ENABLED=1 go test -race ./....
Releasing
Release artifacts are produced by build.sh: one archive per platform in dist/, plus a
checksums.txt with the SHA256 of every archive.
./build.sh # version comes from git describe --tags
./build.sh -v v1.1.0 # or pass it explicitly
Each archive unpacks to a top-level go-websocket-<os>-<arch>/ holding the executable,
start.sh (or start.bat on Windows), env.ini and LICENSE. Windows gets a .zip, the
other platforms get a .tar.gz whose executable and launcher carry the 0755 bit.
The script needs bash (Linux / macOS, or Git Bash / WSL on Windows; Git Bash has no zip,
so the script falls back to PowerShell's Compress-Archive). The version is injected into
main.version, so go-websocket -version reports it.
Publishing is just a tag: .github/workflows/release.yml reruns the same steps
(go vet → go test → build.sh → verify the artifacts), creates the Release and uploads
the artifacts.
git tag v1.1.0 && git push origin v1.1.0
A tag with a suffix (such as v1.1.0-rc1) is published as a prerelease and does not take
over Latest; if the tag already has a Release, its notes are updated and the artifacts
overwritten, so re-tagging is a safe way to republish. To write custom release notes, put
them in docs/release-notes/<tag>.md.
License
MIT © 2026 kite88
Last updated · Docs synced