go-honeybee
WebSocket connection and pool primitives in Go. Built for Nostr.
Library Map
honeybee.go Pool, Worker, public types
transport/ single-connection primitives and helpers
connection.go Connection, state machine, reader goroutine, pinger
config.go ConnectionConfig, RetryConfig, options
retry.go exponential backoff with jitter
socket.go Dialer interface, AcquireSocket
url.go parsing and normalization
watchdog.go IdleWatchdog helper
logging/ structured log construction
logging.go logger constructors, ForcedLevelHandler
types/ shared interfaces (Dialer, Socket, ReceivedMessage)
honeybeetest/ test helpers and mocks for consumers
What This Library Does
Honeybee is a minimal, general-purpose WebSocket transport library.
- Client-Side: Manage a pool of outbound peer connections that reconnect automatically and surface lifecycle events.
- Server-Side: Wrap already-upgraded sockets in the connection primitive, which provides a ping-based heartbeat, automated read-loop, concurrent-safe writes, and classified disconnect errors.
- The same connection primitive may also be used directly on the client side when pool semantics are not needed, providing automated dialing retry with exponential backoff and jitter.
- Exposes a means to completely replace the internal pool worker to inject custom behavior.
What This Library Does Not Do
Honeybee is a pure transport layer, but it is also a deliberately simple one. Honeybee does not provide advanced features, relying on its extensibility features to allow you to customize it.
Honeybee does not provide:
- interpretation of message content. All messages are treated equally.
- message queuing, buffering, prioritization, batching, or coalescing.
- rate limiting, circuit breakers, token buckets, or adaptive throttling.
- broadcast, fanout, or any many-to-many message routing.
- compression strategies, prepared message caching, or encoding optimization.
- authentication, authorization, or session management above the transport.
These are specialized features that deserve robust implementations elsewhere as on-demand extensions rather than core features.
Installation
go get git.wisehodl.dev/jay/go-honeybee
If the primary repository is unavailable, use the replace directive in your go.mod:
replace git.wisehodl.dev/jay/go-honeybee => github.com/wisehodl/go-honeybee latest
Outbound Pool
The pool connects to peers by their URLs and keeps them connected. It reconnects automatically when a connection drops and proactively refreshes inactive connections.
import "git.wisehodl.dev/jay/go-honeybee"
pool, err := honeybee.NewPool(ctx, nil, handler)
if err != nil { /* handle error or panic */ }
defer pool.Close()
if err := pool.Connect("wss://peer.example.com"); err != nil { /* handle error */ }
go func() {
for msg := range pool.Inbox() {
// msg.ID is the normalized URL
// msg.Data is the payload
// msg.ReceivedAt is the timestamp
}
}()
go func() {
for ev := range pool.Events() {
// ev.At is the event timestamp
switch ev.Kind {
case honeybee.EventConnected:
case honeybee.EventDisconnected:
}
}
}()
pool.Send("wss://peer.example.com", []byte("hello"))
Usage Notes:
URLs are normalized by the pool. wss://peer.example.com, wss://peer.example.com/, and WSS://Peer.Example.Com:443 all identify the same peer. honeybee.NormalizeURL is also available directly if you need to use the same URLs as keys elsewhere.
Every time a connection is established, honeybee.EventConnected is emitted. Every time a connection drops for any reason, honeybee.EventDisconnected is emitted. A peer that reconnects three times produces three Connected/Disconnected pairs.
Keepalive is configured via honeybee.WithKeepaliveTimeout. The worker records a heartbeat on every inbound message, every successful send, and every received pong. If no heartbeats arrive before the keepalive timer fires, the connection is proactively disconnected and reconnected. When set to zero, keepalive is disabled.
After a disconnect, the worker waits for ReconnectDelay before attempting the next connection. The default is 2 seconds. Set to zero in tests or when you need immediate reconnection.
Send returns ErrConnectionUnavailable during the gap between a disconnect and the next successful reconnect. Callers should wait for EventConnected before retrying and maintain their own write buffers if needed.
Dial failures are handled internally by the worker's retry logic and documented in structured logs. These do not stop the pool; it continues retrying according to the connection's retry config.
Server-Side Usage
Connection
Use transport.NewConnectionFromSocket when your HTTP upgrade handler gives you an open socket. The connection starts in StateConnected; do not call Connect.
import "git.wisehodl.dev/jay/go-honeybee/transport"
// wsConn is a *websocket.Conn from your upgrade handler
conn, err := transport.NewConnectionFromSocket(wsConn, nil, logger)
if err != nil { /* handle error */ }
defer conn.Close()
go func() {
for data := range conn.Incoming() { // data: []byte
// process incoming messages
}
}()
go func() {
for err := range conn.Errors() {
// log or handle disconnects / read errors
}
}()
// Send can be called concurrently
conn.Send([]byte("hello"))
Send is safe for concurrent callers. Close is idempotent and safe to call from any goroutine.
When the reader exits, exactly one classified error reaches Errors() before the channel closes.
ErrPeerClosedCleanfor normal closureErrPeerClosedUnexpectedfor abnormal close codesErrReadErrorfor anything else
Pass an *slog.Logger as the third argument to get structured logs. Pass nil to disable logging entirely.
IdleWatchdog
The watchdog helper detects clients that have gone silent. Wire activity signals from your Incoming() consumer, on each Send() call, and from the connection's Heartbeat() channel into it, and provide a callback to invoke when the timeout fires. Feeding all three sources means a client that neither sends or receives data but still responds to pings will not be considered idle. Since there is no reconnect loop on the server side, the typical callback is conn.Close().
import "time"
activity := make(chan struct{}, 1)
signal := func() {
select {
case activity <- struct{}{}:
default:
}
}
// Feed data messages:
go func() {
for data := range conn.Incoming() {
signal()
// process data
}
}()
// Feed sends:
func SendWithHeartbeat(conn *Connection, data []byte) error {
err := conn.Send(data)
if err != nil {
return err
}
signal()
return nil
}
// Feed pong heartbeats:
go func() {
for range conn.Heartbeat() {
signal()
}
}()
// Start the watchdog:
go transport.IdleWatchdog(ctx, activity, 30*time.Second, func() {
conn.Close()
})
When no activity signal arrives within the timeout, onTimeout is called once and the watchdog exits. When ctx is cancelled, the watchdog exits without calling onTimeout. When the timeout is zero or negative, the watchdog drains activity signals and waits for ctx to be cancelled without ever firing.
Bare Connection
Use transport.NewConnection when you need a single outbound connection without pool semantics — for example, a one-shot query or a custom use-case. It adds several conveniences over a raw socket: a retry loop with exponential backoff, concurrent-safe writes, automatic write deadline enforcement, classified disconnect errors, and observable connection state.
import "git.wisehodl.dev/jay/go-honeybee/transport"
conn, err := transport.NewConnection("wss://example.com", nil, nil)
if err != nil { /* handle error */ }
if err := conn.Connect(ctx); err != nil { /* handle error */ }
defer conn.Close()
go func() {
for data := range conn.Incoming() { // data: []byte
// process incoming messages
}
}()
go func() {
for err := range conn.Errors() {
// log or handle disconnects / read errors
}
}()
// Send can be called concurrently
conn.Send([]byte("hello"))
The connection goes through four states: StateDisconnected, StateConnecting, StateConnected, StateClosed. Transitions are atomic and observable via conn.State(). Once closed, the connection should not be reused; construct a new one with the same URL and reconnect.
Send is safe for concurrent callers. Close is idempotent and safe to call from any goroutine.
When the reader exits, exactly one classified error reaches Errors() before the channel closes.
ErrPeerClosedCleanfor normal closureErrPeerClosedUnexpectedfor abnormal close codesErrReadErrorfor anything else
Ping-Pong Heartbeats
Connections send periodic WebSocket ping frames and listen for the corresponding pong replies. A received pong registers as a heartbeat signal within the worker.
Pong-derived heartbeats reset the keepalive timer alongside data messages and sends. A peer that sends no data but responds to pings will not be disconnected and reconnected by the keepalive mechanism.
The ping interval is configured via transport.WithPingInterval on the transport.ConnectionConfig. Import git.wisehodl.dev/jay/go-honeybee/transport to construct a ConnectionConfig, then pass it to the pool by value via honeybee.WithConnectionConfig, or supply it directly to NewConnection and NewConnectionFromSocket. The default is 20 seconds. Set to zero to disable pings entirely, in which case only data messages and outbound sends generate heartbeats.
Statistics
Pools, workers, and connections expose counters and channel depths that can be sampled at any time. All values are snapshots; counters are monotonically increasing and are not reset between reconnects.
// Pool-level snapshot
stats := pool.Stats()
// stats.PeerCount — number of currently registered peers
// stats.TotalReceived — messages delivered to pool.Inbox() since construction
// stats.TotalSent — messages sent via pool.Send() since construction
// stats.ChanInbox — current depth of the inbox channel
// stats.PeerStats — one entry per connected peer
// Single peer
peerStats, err := pool.PeerStats(peerID)
// peerStats.Worker — channel depths, processed/sent counts
// Bare connection (transport package)
connStats := conn.Stats() // conn is a *transport.Connection
// connStats.TotalReceived, connStats.TotalSent, connStats.TotalHeartbeats
Extending Pools
The pool owns peer registration, event plumbing, and lifecycle. The worker owns what happens on the wire. The default worker can be replaced entirely via WorkerFactory.
See EXTEND.md for the worker interface contract, the PoolPlugin fields, and extension patterns.
Configuration
All configuration is done through option functions applied at construction time. There are three config scopes: ConnectionConfig, WorkerConfig, and PoolConfig. Logging can be enabled and its minimum level overridden independently at the pool, worker, and connection levels.
See CONFIG.md for the full option reference and defaults table.
Testing
Run the full suite:
go test ./...
Run with the race detector; the suite is race-clean:
go test -race ./...
Test Helpers for Consumers
The honeybeetest package provides mocks and assertions for code that builds on Honeybee:
MockSocketimplementshoneybeetest.Socketwith pluggable function fields for every method, includingWriteControlandSetPongHandler.MockDialerimplementshoneybeetest.Dialerwith a pluggableDialContextFunc.MockSlogHandlercapturesslogrecords for assertions against log output. Child handlers produced viaWithAttrsshare the same record slice as the parent, so attributes added by the logging package appear on the correct records.Eventually(t, condition, msg)polls a condition until it holds or the test timeout expires.Never(t, condition, msg)asserts a condition never holds over a short window.ExpectWrite(t, ch, msgType, data)asserts the next write on the channel matches the expected type and payload.ExpectIncoming(t, ch, data)asserts the next received message matches.AssertLogSequence(t, records, expected)asserts that a slice ofExpectedLogvalues appears in order within a set of records, using forward-only matching and allowing gaps.FindLogRecord(records, level, msgSnippet)returns the first record matching the given level and message substring, or nil.AssertAttributePresent(t, record, key, value)checks that a specific structured attribute is present on a record and equal to the expected value.
Timer-driven paths use short real sleeps (tens of milliseconds). State-transition paths use Eventually and complete as soon as the condition is observed.