gosocketio

module
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 15, 2026 License: MIT

README

gosocketio

A pure-Go implementation of the Socket.IO protocol (Engine.IO v4, Socket.IO v5) with a single dependency: github.com/coder/websocket.

It ships both a server and a client, and interoperates with the official socket.io and socket.io-client Node.js packages.

Features

  • Server and client in one module, split across two layers:
    • github.com/kingecg/gosocketio/engineio — the Engine.IO transport layer (polling and websocket, upgrades, ping/pong, session management).
    • github.com/kingecg/gosocketio/socketio — the Socket.IO message layer (namespaces, events, acknowledgements, binary payloads, reconnection).
  • Namespaces with per-namespace handlers, connection middleware and explicit pre-registration (RegisterNamespace).
  • Rooms with room-scoped broadcasts, per-socket exclusion (ToExcept/Except) and a pluggable adapter (in-memory by default, bring your own, e.g. Redis).
  • Acknowledgements on both the server and the client, with strongly typed handler signatures (no boilerplate casting).
  • Binary events (BINARY_EVENT / BINARY_ACK) transparently split into attachments; []byte and io.Reader payloads accepted on emit.
  • Automatic reconnection on the client with backoff (per-namespace auth preserved across reconnects), mirroring socket.io-client behaviour.
  • Server configuration via NewServerWithConfig: mount path guard and CORS (allow-all or explicit origin allow-list, polling + websocket).
  • Error hooks (OnError on server and client) and a catch-all OnAny event hook on the server.
  • Typed error sentinels matchable with errors.Is (ErrInvalidPacket, ErrHeartbeatTimeout, ErrPayloadTooLarge, ErrNamespaceNotConnected, ErrHandlerMismatch).
  • maxPayload truncation drops overflow packets past the configured buffer limit instead of killing the connection.
  • Interoperability tested against the official Node.js server and client.

Installation

go get github.com/kingecg/gosocketio

Requires Go 1.21+.

Server

The server is an http.Handler. Mount it on any path (the official clients use /socket.io/):

package main

import (
	"log"
	"net/http"

	"github.com/kingecg/gosocketio/socketio"
)

func main() {
	srv := socketio.NewServer(nil)

	srv.OnConnect("/", func(s *socketio.Socket) error {
		log.Printf("%s connected", s.ID())
		return nil
	})
	srv.OnDisconnect("/", func(s *socketio.Socket, reason string) {
		log.Printf("%s disconnected: %s", s.ID(), reason)
	})
	srv.OnEvent("/", "message", func(s *socketio.Socket, text string) {
		srv.BroadcastToNamespace("/", "message", s.ID(), text)
	})
	// Acknowledgement: the return value is sent back to the client.
	srv.OnEvent("/", "ping", func(s *socketio.Socket) string {
		return "pong"
	})

	log.Fatal(http.ListenAndServe(":3000", srv))
}
Namespaces

Register handlers per namespace and connect additional namespaces from the client:

srv.OnEvent("/admin", "stats", func(s *socketio.Socket) map[string]any {
	return map[string]any{"users": srv.Engine().ClientsCount()}
})

The client connects an extra namespace with ConnectNamespace:

err := c.ConnectNamespace(ctx, "/admin", map[string]any{"token": "secret"})

Namespaces are created lazily on first use. To register one explicitly ahead of time (e.g. so a factory-applied adapter is assigned to it):

err := srv.RegisterNamespace("/admin")

RegisterNamespace returns an error for a malformed name (missing leading / or empty); registering the same namespace twice is a no-op.

Middleware

Use runs before a namespace connection is accepted. Return an error to reject the connection with a CONNECT_ERROR:

srv.Use("/admin", func(s *socketio.Socket, data map[string]any) error {
	if data["token"] != "secret" {
		return errors.New("unauthorized")
	}
	return nil
})
Server configuration

NewServer takes only engine options. Use NewServerWithConfig to also set a mount path guard and CORS rules:

srv := socketio.NewServerWithConfig(&socketio.ServerConfig{
	Engine: opts,                       // engineio.Options (nil = defaults)
	Path:   "/socket.io",               // serve only under this prefix ("" = no guard)
	CORS:   socketio.AllowAll(),        // or &CORSConfig{AllowedOrigins: []string{"https://app.example.com"}}
})
  • Path guards the handler: requests outside the prefix get 404.
  • CORS applies when set: preflight OPTIONS is answered with the proper headers, disallowed origins get 403, and websocket upgrades check the Origin header too.
  • NewServer(opts) is exactly NewServerWithConfig(&ServerConfig{Engine: opts}) and adds no path guard or CORS.
Rooms
srv.OnEvent("/", "join", func(s *socketio.Socket, room string) {
	s.JoinRoom(room)
})
srv.OnEvent("/", "room message", func(s *socketio.Socket, room, text string) {
	srv.BroadcastToRoom("/", room, "message", text)
})

Server.BroadcastToRoom includes the sender. A socket's own To(room) operator targets everyone in the room except the sender, matching the official socket.to(room) semantics.

To broadcast to a room while excluding specific sockets, use ToExcept:

// Server-level: exclude ids A and B from the room broadcast.
srv.ToExcept("/", "room", []string{idA, idB}, "message", "hello")

// Socket-level: excludes the sender plus any extra ids, like socket.to(room).except(ids).
s.ToExcept("room", []string{idA}).Emit("message", "hello")

// Or chain on the room operator directly.
s.To("room").Except("idA").Emit("message", "hello")

ToExcept with an empty exclusion list behaves like To(room) / BroadcastToRoom.

Client

c, err := socketio.Dial(context.Background(), "http://localhost:3000/socket.io/", nil)
if err != nil {
	log.Fatal(err)
}
defer c.Close()

c.OnEvent("/", "message", func(from, text string) {
	fmt.Printf("%s: %s\n", from, text)
})
c.OnEvent("/", "ping", func() string {
	return "pong"
})

c.Emit("/", "message", "hello")
result, err := c.EmitWithAck("/", "ping", func(args []any) { ... }, "hi")
Options
c, err := socketio.Dial(ctx, url, &socketio.Options{
	Auth: map[string]any{"token": "..."}, // sent with the CONNECT packet
	Reconnection:        true,
	ReconnectionAttempts: 10,
	ReconnectionDelay:    1 * time.Second,
	ReconnectionDelayMax: 5 * time.Second,
	Timeout:              10 * time.Second,
})
Option Default Purpose
Transports ["polling", "websocket"] Transports to use, in order.
Auth nil Extra data sent with the namespace CONNECT packet.
Reconnection false Reconnect automatically after a transport drop.
ReconnectionAttempts 0 Max reconnect attempts (0 = retry forever).
ReconnectionDelay 1s Initial backoff between attempts.
ReconnectionDelayMax 5s Maximum backoff.
RandomizationFactor 0.5 Random delay jitter.
Timeout 10s Timeout for the initial connection.
Handler signatures

Handlers are called through reflection, so argument types are checked and decoded for you. Parameters after *Socket map to event arguments; return values become the acknowledgement payload.

  • func(s *Socket)
  • func(s *Socket, text string, count int, meta map[string]any)
  • func(s *Socket, payload *T)

The same applies to client handlers without the *Socket parameter. Non-nil error return values are sent as their message string.

Binary payloads

Any value passed through an event or an acknowledgement is encoded as JSON. []byte values are sent as binary attachments. io.Reader values are also accepted and read to memory at emit time:

c.OnEvent("/", "download", func() []byte {
	return []byte("file contents")
})
srv.OnEvent("/", "download", func(s *socketio.Socket) []byte {
	return []byte("file contents")
})

// Emitting an io.Reader is equivalent to emitting its []byte contents.
srv.OnEvent("/", "upload", func(s *socketio.Socket, f *os.File) {
	c.Emit("/", "upload", f)
})

An io.Reader that fails to read is dropped with a warning instead of corrupting the connection.

Error handling

The package exposes typed sentinel errors that work with errors.Is:

var (
	// engineio layer
	ErrInvalidPacket      // transport packet could not be decoded
	ErrHeartbeatTimeout   // peer stopped answering pings
	ErrPayloadTooLarge    // single polling packet exceeds maxPayload

	// socketio layer
	ErrNamespaceNotConnected // emit on a namespace the client never connected (wraps ErrNotConnected)
	ErrHandlerMismatch       // handler signature does not match the event args
)

OnError hooks receive dispatch failures (bad handler signatures, wrong argument types) instead of silently dropping them:

// Server: the *Socket of the sender is provided.
srv.OnError("/", func(s *socketio.Socket, err error) {
	log.Printf("%s handler error: %v", s.ID(), err)
})

// Client: same hook without the socket.
c.OnError("/", func(err error) {
	log.Printf("handler error: %v", err)
})

OnAny is a catch-all hook fired for every event (before the named handler):

srv.OnAny("/", func(s *socketio.Socket, event string, args []any) {
	log.Printf("%s -> %s %v", s.ID(), event, args)
})

Adapters

Rooms and socket membership are backed by a pluggable Adapter. The default NewMemoryAdapter is a single-node in-memory implementation. Provide your own (e.g. backed by Redis) via ServerConfig.Adapter or SetAdapterFactory:

type Adapter interface {
	AddSocket(id string)
	RemoveSocket(id string)
	AddToRoom(room, id string)
	RemoveFromRoom(room, id string)
	Broadcast(room string, except []string, deliver func(id string))
	Sockets() []string
	SocketsCount() int
	Close() error
}

type AdapterFactory func(nsp string) Adapter

srv := socketio.NewServerWithConfig(&socketio.ServerConfig{
	Adapter: func(nsp string) socketio.Adapter {
		return myRedisAdapter(nsp)
	},
})
// Or later — applies only to namespaces created after the call:
srv.SetAdapterFactory(func(nsp string) socketio.Adapter {
	return myRedisAdapter(nsp)
})

Server.Close() calls Close() on every namespace's adapter.

Interoperability

socketio/interop_test.go runs both directions against the official Node.js implementation:

  • Go client → Node server: events, binary events, acknowledgements, namespaces, connection errors and transport upgrades.
  • Node client → Go server: the same surface in reverse, driven by interop/js-client/test.mjs.

Run them with the Go tests (they locate the Node server automatically):

go test ./socketio/ -run Interop -v

Examples

See examples/ for a runnable chat server and client:

go run ./examples/chat-server
go run ./examples/chat-client

Development

go test ./... -race

Tests exercise real Engine.IO polling and websocket sessions, transport upgrades, and race-free concurrent broadcast and reconnection paths.

Directories

Path Synopsis
Package engineio implements the Engine.IO v4 protocol layer: session management, HTTP long-polling and WebSocket transports, transport upgrades and the heartbeat.
Package engineio implements the Engine.IO v4 protocol layer: session management, HTTP long-polling and WebSocket transports, transport upgrades and the heartbeat.
transport
Package transport implements the Engine.IO v4 protocol primitives (packet/payload encoding) and the underlying HTTP long-polling and WebSocket transports.
Package transport implements the Engine.IO v4 protocol primitives (packet/payload encoding) and the underlying HTTP long-polling and WebSocket transports.
examples
chat-client command
chat-server command
Package socketio implements the Socket.IO v5 protocol layer on top of the engineio transport package.
Package socketio implements the Socket.IO v5 protocol layer on top of the engineio transport package.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL