Skip to content

Connections

Gerasimos (Makis) Maropoulos edited this page Oct 6, 2026 · 1 revision

When to use this page

  • You need to know which of Conn, NSConn or Room to reach for.
  • You're storing per-connection state (a user record, a rate-limit counter) and want it to live and die with the connection.
  • You want to list, inspect or act on every connection the server currently holds.
  • You're deciding how a connection gets its ID, or handling connect/disconnect at the whole-connection level rather than per namespace.

Conn, NSConn and Room

One websocket is one *neffos.Conn. It can be connected to zero or more namespaces, each represented by its own *neffos.NSConn (see Namespaces), and each NSConn can be joined to zero or more rooms, each a *neffos.Room (see Rooms). This page is about the outermost layer, Conn itself: identity, per-connection storage, server-wide inspection, and the whole-connection lifecycle hooks.

Identity

type IDGenerator func(w http.ResponseWriter, r *http.Request) string

var DefaultIDGenerator IDGenerator = func(http.ResponseWriter, *http.Request) string {
	// a random UUID v4, or a unix timestamp if that fails.
}

Server.IDGenerator runs once per handshake, server-side, and its return value becomes Conn.ID(). The default is a random UUID, unique for practical purposes across any number of servers. A custom generator, like the chat examples' "use the authenticated user's name", is not: two different server processes can both mint the ID "alice" for two different physical connections. That is fine for a single server, and fine for scale out too as long as you accept that a Message.To-targeted message reaches whichever connection currently holds that ID, on whichever server subscribes to it.

A Go client sends identifying information as a real handshake header; a browser cannot set custom headers during a websocket handshake, so it has to go as a URL query parameter instead. A generator that supports both looks like the one in _examples/01-getting-started/02-namespaces onward:

srv.IDGenerator = func(w http.ResponseWriter, r *http.Request) string {
	if name := r.Header.Get("X-Username"); name != "" {
		return name
	}
	if name := r.URL.Query().Get("name"); name != "" {
		return name
	}
	return neffos.DefaultIDGenerator(w, r)
}

Per-connection storage

func (c *Conn) Set(key string, value any)
func (c *Conn) Value[T any](key string) (T, bool)
func (c *Conn) Increment(key string) int
func (c *Conn) Decrement(key string) int

A Conn carries its own map[string]any, guarded by a lock, that lives exactly as long as the connection. Set stores anything; Value[T] reads it back typed, and reports false when the key is missing or holds a value of another type, so there is no type assertion to write (Value[any] returns whatever was stored, which is what the old Get did). Increment/Decrement treat the stored value as an int and are safe to call concurrently from different event callbacks on the same connection, which makes them a convenient per-connection counter without reaching for your own sync/atomic.

c.Conn.Set("user", user)

// later, in another event of the same connection:
user, ok := c.Conn.Value[User]("user")
if !ok {
	return errors.New("not authenticated")
}

A rate-limit handler is the common use: count events in a window, and close the connection once it floods. _examples/05-connections/rate-limit keeps a one-second sliding window with nothing but Increment and a deferred Decrement:

neffos.Events{
	"Say": func(c *neffos.NSConn, msg neffos.Message) error {
		if n := c.Conn.Increment("recent"); n > limit {
			log.Printf("[%s] closed: %d messages in the last %s", c.Conn.ID(), n, window)
			return neffos.CloseError{Code: neffos.ClosePolicyViolation, Reason: "rate limit"}
		}
		time.AfterFunc(window, func() { c.Conn.Decrement("recent") })

		msg.Body = fmt.Appendf(nil, "%s: %s", c.Conn.ID(), msg.Body)
		c.BroadcastOthers(msg)
		return nil
	},
}

Each message increments "recent" and schedules a matching Decrement one second later, so "recent" always holds the count from the last second; cross that count and the handler returns a neffos.CloseError instead of relaying the message. Returning a CloseError from a handler closes the connection with that code and reason instead of just sending an error reply; see Close codes and shutdown.

Inspecting live connections

func (s *Server) GetConnections() map[string]*Conn
func (s *Server) GetConnectionsByNamespace(namespace string) map[string]*NSConn
func (s *Server) GetTotalConnections() uint64

GetTotalConnections is an atomic read, cheap enough to call as often as you like. GetConnections and GetConnectionsByNamespace build a fresh map on every call and are explicitly documented as not fast: use them for debugging, logging, or an occasional admin endpoint, not on a hot path. Both are safe to call concurrently, including from inside a Server.Do callback (below).

_examples/05-connections/inspect-and-do's list command combines all three into one line per connection:

// list logs every connection with the namespaces it is in.
func list(srv *neffos.Server) {
	in := map[string][]string{} // connection ID to namespaces
	for _, ns := range namespaces {
		for id := range srv.GetConnectionsByNamespace(ns) {
			in[id] = append(in[id], ns)
		}
	}

	conns := srv.GetConnections()
	var lines []string
	for _, id := range slices.Sorted(maps.Keys(conns)) {
		line := fmt.Sprintf("%s (%s", id, strings.Join(in[id], ", "))
		if c := conns[id]; c.WasReconnected() {
			line += fmt.Sprintf(", reconnect %d", c.ReconnectTries)
		}
		lines = append(lines, line+")")
	}
	log.Printf("%d connections: %s", srv.GetTotalConnections(), strings.Join(lines, ", "))
}

Acting on every connection: Server.Do

func (s *Server) Do(fn func(*Conn), async bool)

Do runs fn once per currently connected Conn. With async == false it blocks until every connection has been visited; with true it returns immediately. fn runs on the server's own dispatch goroutine, so keep it short: long work inside fn delays other Do calls, disconnect notifications and synchronous broadcasts until it returns. The same example's "news <text>" operator command uses it to reach only the connections that entered a second namespace:

switch cmd {
case "news":
	sent := 0
	srv.Do(func(c *neffos.Conn) {
		if ns := c.Namespace("news"); ns != nil && ns.Emit("Headline", []byte(arg)) {
			sent++ // Do with async=false returns after the last call, so no lock is needed
		}
	}, false)
	log.Printf("headline sent to %d of %d", sent, srv.GetTotalConnections())
}

"drop <name>", in the same example, calls Conn.DisconnectAll from outside Do, since it is slow enough (it waits for the client's answer) that it should not run on the dispatch goroutine:

for scanner.Scan() {
	switch cmd {
	case "drop":
		c, ok := srv.GetConnections()[arg]
		if !ok {
			log.Printf("drop %s: not connected", arg)
			continue
		}
		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
		if err := c.DisconnectAll(ctx); err != nil {
			log.Printf("drop %s: %v", arg, err)
		}
		cancel()
	}
}

Conn.DisconnectAll(ctx) leaves from every namespace and room but keeps the socket; Conn.Close() and Conn.Terminate(code, reason) end the socket itself (Close is Terminate(CloseNormalClosure, "")). See Close codes and shutdown for the difference between closing one connection this way and shutting the whole server down.

Lifecycle hooks

type Server struct {
	OnUpgradeError       func(err error)
	OnConnect            func(c *Conn) error
	OnDisconnect         func(c *Conn)
	FireDisconnectAlways bool
}
  • OnUpgradeError runs when the HTTP-to-websocket upgrade itself fails, before any Conn exists; there is nothing to act on but the error.

  • OnConnect runs once the handshake succeeds, before the connection can send or receive anything else. A non-nil return refuses the connection: the remote side gets the error text, and the socket closes right after. Return a plain error to close with the default code (ClosePolicyViolation, 1008), or a neffos.CloseError{Code: ..., Reason: ...} to choose the code yourself, the same as returning one from an event handler.

  • OnDisconnect runs once the connection is gone, from any cause. c.Err() says what closed it, and neffos.CloseStatus(c.Err()) turns that into a close code. A peer that drops with a clean FIN and no close frame still reports a code, neffos.CloseAbnormalClosure (1006), on all three backends; CloseStatus returns -1 only when there was no code at all (a timeout, the heartbeat, or a hard TCP reset):

    srv.OnDisconnect = func(c *neffos.Conn) {
    	switch err := c.Err(); {
    	case errors.Is(err, neffos.ErrMessageTooBig):
    		log.Printf("[%s] sent an oversized message", c.ID())
    	case neffos.IsTimeoutError(err):
    		log.Printf("[%s] timed out", c.ID())
    	default:
    		log.Printf("[%s] disconnected, close status %d", c.ID(), neffos.CloseStatus(err))
    	}
    }
  • FireDisconnectAlways (default false) controls one edge case: when OnConnect itself calls Close() or returns a non-nil error, should OnDisconnect still fire for that connection? Set it to true if your bookkeeping (metrics, a connection registry) needs to see a disconnect for every connect it saw, including ones OnConnect rejected.

Reconnects

type Conn struct {
	ReconnectTries int
	// ...
}

func (c *Conn) WasReconnected() bool

WasReconnected reports whether the current connection is the result of a client redialing after a drop, and ReconnectTries is how many attempts that took. Both are meaningful inside OnConnect, inside any event handler, or in OnDisconnect. See Reconnection for how the client side signals this and what the server does with the X-Websocket-Reconnect header.

Multiple connections, one user

Nothing in neffos groups connections by anything other than their Conn.ID(). A user with a phone and a laptop open is, as far as the server is concerned, two unrelated connections. Grouping them, sending to "all of this user's devices", and deciding what "online" means once there can be more than one connection per user, is application code: a small registry keyed by user, populated in OnNamespaceConnected and cleaned up in OnNamespaceDisconnect. See _examples/05-connections/users-and-devices for a complete version ("name#n" connection IDs, a registry of *NSConn sets, and a Tell event that reaches every device of a user plus a copy back to the sender's other devices).

Examples

  • _examples/05-connections/users-and-devices: one user, several connections, a registry that tracks "online" across all of a user's devices.
  • _examples/05-connections/rate-limit: Increment/Decrement plus a CloseError for a connection that floods.
  • _examples/05-connections/inspect-and-do: GetConnections, GetConnectionsByNamespace and Server.Do from an admin command.

See also

Clone this wiki locally