Guidance for AI coding agents working in the wshub repository.
wshub is a production-ready, horizontally scalable WebSocket package for Go
(github.com/KARTIKrocks/wshub). It manages WebSocket connections with rooms,
broadcasting, multi-node clustering, middleware, hooks, backpressure control,
and observability. It is pure infrastructure — it ships no business logic.
The central type is Hub (hub.go), created with NewHub and configured via
functional options (options.go). See doc.go for the package-level overview.
This is a multi-module repository. Each of these has its own go.mod and
must be built/tested independently:
.(root) — core package, no third-party runtime deps beyond the WebSocket lib.prometheus/— official Prometheus metrics subpackage.adapter/redis/— Redis Pub/Sub adapter for multi-node scaling.adapter/nats/— NATS core Pub/Sub adapter for multi-node scaling.examples/multinode/— its own module because it depends onadapter/redis. It ships no tests; CI builds it to catch API drift.
The submodules are versioned independently of the core package (the adapters
are on their own v0.2.x line) and each pins a published wshub version in its
go.mod. See the workspace note under Build, test, and lint for how that pin
is replaced with the working tree.
The core package ships no adapter implementations on purpose — adapters live
in submodules so consumers don't pull in unwanted dependencies. When touching the
Adapter interface (adapter.go), update both adapter/redis and adapter/nats.
hub.go— core Hub, dispatch loop, connection lifecycle, draining, shutdown.client.go— per-connection read/write pumps.config.go/options.go/limits.go— configuration & builder pattern.adapter.go— multi-nodeAdapterinterface.router.go/middleware.go/hooks.go— message routing & extensibility.presence.go— cross-node presence gossip for global counts.metrics.go/health.go— observability and K8s probes.
Always run make all after making changes — it vets, lints, tests, and
builds every module, not just the root. This is the expected validation gate.
make all # fmt + vet(+modules) + lint(+modules) + test(+modules) + build(+examples)
make test # root module only: go test -race -count=1 ./...
make test-modules # adapter/redis, adapter/nats, prometheus
make lint # golangci-lint on the root module (make setup installs it if missing)
make lint-modules # golangci-lint on the nested modules
make vuln # govulncheck on the root module (needs network)
make vuln-modules # govulncheck on the nested modules
make work # create the gitignored go.work used by the module targets
make tidy # go mod tidy on the root module
make tidy-modules # go mod tidy on the nested modules (run after bumping their wshub pin)
make tidy-check # fail if any go.mod/go.sum is untidy, leaving no diff behind
make fix # gofmt + goimports + golangci-lint --fix
make bench # benchmarks with -benchmem
make fuzz # fuzz targets (30s each)
make cover # coverage report -> coverage.html- Tests must pass with the race detector (
-race). Concurrency correctness is a first-class requirement here. make lint/make setuprequiregolangci-lintv2 andgoimports(pinned versions in theMakefile). The ruleset in.golangci.ymlis clean across every module —gosec,errorlint,bodyclose,noctx,contextcheckandperfsprintare all enabled on library code, and relaxed only for_test.go,examples/andcmd/.- The root module's
./...stops at nestedgo.modboundaries, so the module targets above are what cover the adapters and the Prometheus collector. They resolvewshubthrough a gitignoredgo.workpointing at the working tree, which is how CI runs them too (viago mod edit -replace) — so a breaking change in the root module fails locally rather than after release.
- Go 1.27+ for every module — the core package and all submodules declare the same floor.
- Follow standard Go style;
gofmt+goimportsare mandatory (make fmt). - New exported types and functions must have doc comments starting with the
identifier name — they are the pkg.go.dev reference. This is not yet enforced
by lint (
revive'sexportedrule is disabled in.golangci.yml) because some existing methods inmetrics.go/logger.gopredate the rule; do not add to the backlog. - Keep the hot path zero-allocation — benchmarks in
benchmark_test.goguard dispatch performance. Verify withmake benchif you touch dispatch/send code. - Never
close(client.send)in library code. Producers (Hub.trySendErr,Client.SendMessageWithContext) send on that channel without holding any lock a closer could take, so closing it races with them. Shutdown is signalled throughclient.doneinstead —CloseWithCodesetsgracefulsowritePumpflushes the queue and sends a close frame;handleUnregisterdrains the buffer. Tests may closesendon a bare&Client{}with nowritePumprunning to exercise the recover guards. - Keep test coverage high; add tests alongside new functionality.
- Update
README.md/doc.gowhen the public API changes, and add aCHANGELOG.mdentry for user-facing changes.
- Keep changes focused on a single concern; include tests.
- Ensure
make all(or at leastmake ci) passes before opening a PR. - One branch per change. Never add follow-up commits to a branch that has
already been merged — branch again off updated
main. - Branch names are
type/kebab-slug, using the same type as the commit:fix/close-with-code-send-channel-race,ci/test-submodules,docs/docusaurus-website. - Commits follow Conventional Commits:
type(optional-scope): imperative summary, no trailing period. Types arefeat,fix,docs,test,ci,build,chore,perf,refactor,revert. Security fixes arefix(security):; dependency bumps arechore(deps):(ci(deps):for Actions), which is what Dependabot is configured to emit. - PRs are squash-merged, so the PR title becomes the commit on
mainand must itself be a valid Conventional Commit. Intermediate commits on the branch are working state and do not need to be tidy — they will be collapsed. - Never push directly to
main; every change goes through a PR, including single-line ones. - CI (
.github/workflows/ci.yml) tests the root module and each submodule at two self-tracking legs:go-version-file(whatever that module's owngo.moddeclares as the floor) andstable(whatever Go's current release is). Neither leg is a hardcoded version string, so a floor bump or a new Go release needs no workflow edit. It also uploads coverage to Codecov. Thecijob is an aggregate gate and is meant to be the only required status check — requiring the matrix jobs directly orphans the required context whenever a matrix value changes. Linting covers the four library modules — root,adapter/redis,adapter/nats,prometheus— matchingmake lint-modules;examples/multinodeis only built, since it ships no tests and exists to catch API drift.codecov.ymlignoresexamples/,cmd/and the website, so coverage reflects library code only.
Releases have their own ordering constraints because this is a multi-module
repository, and Go module tags are permanent once the proxy has served them —
read RELEASING.md before tagging anything.
Two bots review every PR; both are config-as-code and should stay in sync with these conventions when they change:
- CodeRabbit —
.coderabbit.yaml. Advisory only (request_changes_workflow: false); CI is the actual merge gate. - Greptile —
.greptile/(config.jsonfor scoped rules and settings,rules.mdfor freeform style guidance,files.jsonfor context files it should read). Also advisory.
Both carry per-path guidance roughly mirroring each other (root Go files vs.
adapter/** vs. client.go/hub.go's no-close-send-channel rule vs.
website/docs/**'s version-marker requirement). If you change a convention
documented in this file that either bot enforces, update both configs in the
same PR — a stale bot rule actively misleads the next contributor it comments
on.
examples/— runnable examples (simple, chat, auth, metrics, multinode).cmd/loadtest/— real-WebSocket load generator:make loadtest LOADTEST_ARGS="-scenario=fanout -clients=10000".
The docs site lives on main in website/ and is built with Docusaurus
(TypeScript, Biome for lint/format). It is published to GitHub Pages by
.github/workflows/docs.yml on every push to main that touches website/ —
there is no manual deploy step and nothing to mirror onto another branch.
cd website
npm ci
npm start # preview at localhost:3000
npm run check # lint + typecheck + build — what the Docs workflow runsnpm run lint is biome check, which covers formatting as well as linting.
Biome has no Markdown support, so prose is linted separately and repo-wide with
make lint-docs (config: .markdownlint-cli2.jsonc).
Versioning is by snapshot, not per release. website/docs/ is the
unreleased/current documentation; website/versioned_docs/version-1.7/ is a
frozen snapshot of what a released version does.
- Never edit
website/versioned_docs/. Changing a snapshot rewrites history for users still on that version. Snapshots are cut deliberately withwebsite/scripts/cut-version.mjs; seewebsite/VERSIONING.md. - Public API change — update the matching page under
website/docs/and mark the version inline rather than cutting a new snapshot: append_1.8+_to an API table cell, open a paragraph with_Added in 1.8._, add a trailing// 1.8+comment in a code block, or write_Changed in 1.8._plus one line on the previous behaviour.
Internal links are checked at build time (onBrokenLinks: 'throw'), so a
renamed page fails the Docs workflow rather than shipping a dead link. External
links are not checked.