Skip to content

Struct handlers

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

When to use this page

  • Your neffos.Events map has grown past a handful of entries and reads better as methods.
  • You want one value per connection that keeps state across that connection's events (a per-connection cache, a counter, a loaded user record) without a map[*neffos.NSConn]*yourType of your own.
  • You want to reuse the same handlers under more than one namespace, or mix them with plain function handlers.

A Struct[T] is a ConnHandler, the same role Events, Namespaces and WithTimeout play: pass it to neffos.New or neffos.Dial. T is your controller type; you never write it out, NewStruct(&yourType{}) infers it.

Dynamic vs. static

func NewStruct[T any](prototype *T) *Struct[T]

prototype must be a non-nil pointer to a struct. What happens next depends on one thing: does that struct have a field of type *neffos.NSConn?

  • With a *neffos.NSConn field, the controller is dynamic. neffos builds one new instance per connection to the namespace, fills that field with the connection's own NSConn, and copies over any other exported, non-zero field from the prototype you passed to NewStruct (see "Static fields" below). Methods read the connection through that field instead of taking it as a parameter.
  • Without one, the controller is static. There is only ever the one value you passed in. Its methods take the NSConn as a parameter, like a plain neffos.Events handler, and registering it costs nothing extra at request time.

The field can have any name; neffos looks at the type, not the name. The _examples/01-getting-started/10-struct-handler example calls it Conn:

type lobby struct {
	Conn  *neffos.NSConn
	Topic string
}

Method signatures

A method becomes an event if its signature matches one of these two, depending on whether the controller is dynamic or static:

// Dynamic: the controller has a *neffos.NSConn field, so the method reads it from there.
func (c *T) Event(msg neffos.Message) error

// Static: no such field, so the method takes the connection like any other handler.
func (c *T) Event(nsConn *neffos.NSConn, msg neffos.Message) error

Any other signature is skipped, silently, so an unrelated method (a constructor, a private helper with an exported name) does not become an event by accident. From _examples/01-getting-started/10-struct-handler:

func (l *lobby) OnChat(msg neffos.Message) error {
	// l.Conn is this connection's NSConn, filled in by neffos.
	return nil
}

// who reads only the server, not the lobby value, so it stays a plain handler.
func who(c *neffos.NSConn, msg neffos.Message) error {
	return nil
}

who is a package-level function, not a method, so it is never picked up by NewStruct; it is joined back in with JoinConnHandlers (see below).

System events by method name

A method named after one of the system events (OnNamespaceConnect, OnNamespaceConnected, OnNamespaceDisconnect, OnRoomJoin, OnRoomJoined, OnRoomLeave, OnRoomLeft) is registered as that system event, underscore and all, without going through the event matcher. _examples/01-getting-started/10-struct-handler uses this for OnNamespaceConnected, OnNamespaceDisconnect, OnRoomJoin, OnRoomJoined and OnRoomLeft; see Namespaces and Rooms for what each one fires on.

Choosing the namespace

Checked in this order:

// 1. The Struct setter, used by the struct-handler example. If this was
// called, it wins outright; neither of the two below is even looked at.
var controller = neffos.NewStruct(&lobby{}).SetNamespace("chat")
// 2. A method, used only when SetNamespace was never called.
func (l *lobby) Namespace() string { return "chat" }

(A third, lower-level option exists below the method: a plain string field literally named Namespace with a non-empty value set on the struct you pass to NewStruct, used only when neither of the two above applies. SetNamespace and the method cover the normal cases and read better.)

Matching methods to event names

By default, a method's own name is the event name: OnChat stays "OnChat". SetEventMatcher changes that:

func (s *Struct[T]) SetEventMatcher(matcher EventMatcherFunc) *Struct[T]

Two ready-made matchers:

neffos.EventPrefixMatcher(prefix)       // keeps methods whose name starts with prefix; event name is the full method name
neffos.EventTrimPrefixMatcher(prefix)   // same filter, but the prefix is cut from the event name

_examples/01-getting-started/10-struct-handler uses EventTrimPrefixMatcher("On"), so OnChat registers as "Chat", the name its clients already emit, while OnWave becomes "Wave" and so on. System-event methods bypass the matcher entirely, so OnNamespaceConnected keeps its meaning no matter what matcher you set.

Timeouts, heartbeat and size limit

Struct carries the same per-connection settings WithTimeout does, as setters instead of struct fields:

func (s *Struct[T]) SetTimeouts(read, write time.Duration) *Struct[T]
func (s *Struct[T]) SetPingInterval(d time.Duration) *Struct[T]
func (s *Struct[T]) SetMaxMessageSize(n int64) *Struct[T]

All three default to off (no timeout, no heartbeat, no size cap). See Timeouts for what each one does and which Socket interface it needs.

Static fields

On a dynamic controller, every exported, non-zero field of the prototype you passed to NewStruct, other than the *neffos.NSConn field itself, is copied into every new per-connection instance. Unexported fields are never copied (Go does not let neffos set them); a sync.Mutex or a private counter on the controller starts at its zero value on every connection, which is normally what you want. If a private field needs a value, build the instance yourself with SetInjector. _examples/01-getting-started/10-struct-handler uses this for a value that never changes:

var controller = neffos.NewStruct(&lobby{Topic: "be kind, no spoilers"}).
	SetNamespace(namespace).
	SetEventMatcher(neffos.EventTrimPrefixMatcher("On")).
	SetTimeouts(readTimeout, writeTimeout).
	SetPingInterval(pingInterval).
	SetMaxMessageSize(maxMessageSize)

Every lobby built for a new connection starts with Topic already set to "be kind, no spoilers".

SetInjector: building the controller yourself

The default construction above (a fresh *T plus copying static fields) is enough for constants, but it cannot wire in a database handle, a logger, or anything else that is not an exported field value on a prototype. SetInjector replaces that construction step:

func (s *Struct[T]) SetInjector(fn func(nsConn *NSConn) *T) *Struct[T]

fn is called once per connection, in place of the default construction, and returns the *T for that connection with anything it needs beyond the *neffos.NSConn field filled in; neffos sets that field itself, after fn returns, the same way it does for the default. With a custom injector, the static-field copying does not run at all; fn owns every other field. The nsConn argument is the connection the instance is being built for, so an injector can read nsConn.Conn.ID() or nsConn.Conn.Value[T](...) to pick dependencies per user. _examples/04-handlers/struct-injector uses it to hand two shared dependencies, a note store and an audit log, to a controller that has no field for them on its prototype:

// notes handles the "notes" namespace for one connection.
type notes struct {
	Conn *neffos.NSConn // filled by neffos after the injector returns

	store *noteStore
	audit *auditLog
}

notesController := neffos.NewStruct(&notes{}).
	SetNamespace("notes").
	SetEventMatcher(neffos.EventPrefixMatcher("Note")).
	SetInjector(func(_ *neffos.NSConn) *notes {
		return &notes{store: store, audit: log}
	})

The injector builds the whole *notes value itself, with store and log closed over from newHandler's arguments; neffos still fills the Conn field afterwards. Before v0.1.0 the injector took a reflect.Type and returned a reflect.Value; the typed form is the same idea with the compiler checking the type. This example also shows EventPrefixMatcher("Note"): unlike EventTrimPrefixMatcher, it keeps the method's full name as the event name (NoteAdd stays "NoteAdd") and filters out any method whose name does not start with the prefix. (A helper like notes.user() string is never in the running either way: its signature does not match a handler's, so it is skipped before the matcher even runs.)

Reading the controller from outside: Instance

The controller's own methods have the instance as their receiver. Code outside them, a plain handler joined into the same namespace, an OnNamespaceDisconnect registered through Namespaces, an audit hook, can reach a connection's instance through NSConn.Instance:

func (ns *NSConn) Instance[T any]() (*T, bool)

It returns the *T built for that connection, or false before the namespace connected, for a static controller (there is no per-connection value), and when the handler's type is not T.

"Who": func(c *neffos.NSConn, msg neffos.Message) error {
	lobby, ok := c.Instance[lobby]()
	if !ok {
		return neffos.ErrBadNamespace
	}
	return neffos.ReplyObject(lobby.Topic)
},

StructValue: for frameworks that hold a reflect.Value

A dependency injection container usually ends up with the controller as a reflect.Value, not as a *T it can name. For that case the reflection-driven engine behind Struct[T] is exported on its own:

func NewStructValue(v reflect.Value) *StructValue
func (s *StructValue) SetInjector(fn func(nsConn *NSConn) reflect.Value) *StructValue

StructValue has the same setters, Events() and GetNamespaces() as Struct[T], and the same panics for a value that is not a pointer to a struct with exported methods. This is what iris's mvc websocket controller uses. Application code should not need it; NewStruct is the typed front door and keeps reflect out of your imports.

Several controllers, or a controller plus plain handlers

When an application has one namespace, the Struct is already a complete ConnHandler; pass it straight to New or Dial. JoinConnHandlers combines any number of ConnHandlers, Struct included, whether they target the same namespace or different ones: namespaces it has not seen are added, and a namespace both inputs touch gets both sets of events merged (a later handler's event wins on a name collision). _examples/04-handlers/struct-injector mounts two Struct controllers under two different namespaces this way:

srv := neffos.New(gorilla.DefaultUpgrader, neffos.JoinConnHandlers(notesController, auditController))

_examples/01-getting-started/10-struct-handler uses the same function to mix a Struct with a plain function handler in the same namespace, for its one handler that does not need the controller value:

var serverEvents = neffos.JoinConnHandlers(controller, neffos.Namespaces{
	namespace: neffos.Events{"Who": who},
})

JoinConnHandlers also keeps every joined Struct/WithTimeout input's timeouts, heartbeat and size limit; see Timeouts for the settings it carries over.

For the case JoinConnHandlers does not cover, assembling a Namespaces map by hand instead of merging ConnHandlers, Struct[T].Events() returns the raw Events a Struct built, to place under whatever key you choose:

func (s *Struct[T]) Events() Events
namespaces := neffos.Namespaces{
	"chat":  chatController.Events(),
	"admin": adminController.Events(),
}

Pitfalls: when NewStruct panics

NewStruct panics, rather than returning an error, on a handful of programmer mistakes it can catch immediately:

  • prototype is a nil pointer.
  • T is not a struct (the compiler already rejects a non-pointer argument).
  • the struct has no exported methods at all (a Namespace() string method alone does not count, since it configures the namespace rather than handling an event).

All of these are caught once, when you build the Struct, not per connection, so they surface at startup.

Examples

  • _examples/01-getting-started/10-struct-handler: the lobby's handlers as a controller struct, EventTrimPrefixMatcher, static Topic, and JoinConnHandlers with a plain function handler.
  • _examples/04-handlers/struct-injector: a typed SetInjector building each connection's controller with shared dependencies, EventPrefixMatcher, and two controllers joined into two namespaces with JoinConnHandlers.

See also

  • Namespaces, the system events a struct method can implement by name
  • Timeouts, SetTimeouts, SetPingInterval, SetMaxMessageSize
  • Connections, what the *neffos.NSConn/Conn field gives a dynamic controller access to

Clone this wiki locally