Go SDK
github.com/NoLagApp/go-sdk is the NoLag client for Go 1.21+. This page documents v0.5.0. It is MIT licensed.
Installation
go get github.com/NoLagApp/go-sdkQuick start
This connects to the local kraken from the quickstart as the demo user alice, whose token grants demo/general/#:
package main
import (
"fmt"
"time"
nolag "github.com/NoLagApp/go-sdk"
)
func main() {
client := nolag.New("dev-token-alice", nolag.Options{
URL: "ws://localhost:8080/ws",
})
// Broker errors (no grant for a topic, and so on) are asynchronous:
// register the handler before Connect
client.OnError(func(err *nolag.ServerError) {
fmt.Printf("nolag: %v\n", err)
})
if err := client.Connect(); err != nil {
panic(err)
}
defer client.Close()
// A room context prefixes topics with app/room, so this is demo/general/*
room := client.SetApp("demo").SetRoom("general")
err := room.Subscribe("messages", func(data any, meta nolag.MessageMeta) {
fmt.Printf("Received: %v\n", data)
})
if err != nil {
panic(err)
}
if err := room.Emit("messages", map[string]any{"hello": "world"}); err != nil {
fmt.Printf("Emit failed: %v\n", err)
}
fmt.Println("Actor ID:", client.ActorID())
time.Sleep(60 * time.Second)
}Always set Options.URL: the built-in default points at the retired NoLag Cloud broker.
Topics live inside rooms. Every address is app/room/topic. Topic names are single tokens with no /. An address only works if the actor's grants cover it: grants come from your kraken auth configuration, either a static auth.json (Static auth) or a @nolag/core project (Project document). An address the actor has no grant for is refused with unknown_topic (42940) through OnError. See Topics and Rooms.
Configuration
Every field you omit keeps its default. Reconnect and QoS are pointers so that an omitted field is distinguishable from false or QoS 0; set them with nolag.Bool() and nolag.QoSLevel().
options := nolag.Options{
URL: "ws://localhost:8080/ws", // Your kraken's WebSocket URL (always set this)
Reconnect: nolag.Bool(true), // Reconnect automatically (default: true)
ReconnectInterval: 5 * time.Second, // Wait between attempts (default: 5s)
MaxReconnectAttempts: 10, // Default 10; 0 keeps the default, -1 is unlimited
HeartbeatInterval: 30 * time.Second, // Default 30s; 0 keeps the default, -1 disables
QoS: nolag.QoSLevel(nolag.QoSAtLeastOnce), // Default QoS (default: QoSAtLeastOnce)
LoadBalance: false, // Load-balance every subscription (default: false)
LoadBalanceGroup: "workers", // Group name (default: the actor's id)
Debug: true, // Log protocol traffic (default: false)
}
client := nolag.New("dev-token-alice", options)The token is a string, sent as given on every connect. The SDK does not refresh client tokens, so give long-running Go services an actor token that does not expire. Keep the heartbeat on: kraken closes a socket that has been idle for 60 seconds.
Reconnects and subscriptions
With Reconnect on, the client waits ReconnectInterval, connects again, and stops after MaxReconnectAttempts consecutive failures. After a successful reconnect it does two things you should know about:
- It re-subscribes every topic it holds, with the same handler, but without the
SubscribeOptionsyou originally passed. Filters, per-subscription QoS and per-subscription load balancing are dropped; connection-levelOptions(QoS,LoadBalance,LoadBalanceGroup) still apply. A filtered subscription silently becomes an unfiltered one. - It does not re-send presence. Set room presence again in a
connectedhandler, which runs inside every successfulConnect, including reconnects.
client := nolag.New("dev-token-alice", nolag.Options{URL: "ws://localhost:8080/ws"})
room := client.SetApp("demo").SetRoom("general")
client.On("connected", func(args ...any) {
room.SetPresence(map[string]any{"username": "alice", "status": "online"})
})
client.On("disconnected", func(args ...any) {
fmt.Println("Disconnected")
})
client.On("reconnecting", func(args ...any) {
fmt.Printf("Reconnecting, attempt %d\n", args[0].(int))
})
if err := client.Connect(); err != nil {
log.Fatal(err)
}
// Plain subscriptions are re-subscribed by the SDK after a reconnect
room.Subscribe("messages", func(data any, meta nolag.MessageMeta) {
fmt.Println(data)
})If a subscription needs filters or its own options, turn automatic reconnect off and reconnect yourself, re-subscribing with the options after each Connect. A minimal version:
package main
import (
"log"
"sync/atomic"
"time"
nolag "github.com/NoLagApp/go-sdk"
)
func main() {
client := nolag.New("dev-token-alice", nolag.Options{
URL: "ws://localhost:8080/ws",
Reconnect: nolag.Bool(false), // reconnect below, so options are re-applied
})
room := client.SetApp("demo").SetRoom("general")
subscribe := func() {
err := room.Subscribe("messages", func(data any, meta nolag.MessageMeta) {
log.Println("vip message:", data)
}, nolag.SubscribeOptions{Filters: []any{"vip"}})
if err != nil {
log.Println("subscribe:", err)
}
}
var closing, reconnecting atomic.Bool
client.On("disconnected", func(args ...any) {
if closing.Load() || !reconnecting.CompareAndSwap(false, true) {
return
}
go func() {
defer reconnecting.Store(false)
for !closing.Load() {
time.Sleep(5 * time.Second)
if err := client.Connect(); err == nil {
subscribe()
return
}
}
}()
})
if err := client.Connect(); err != nil {
log.Fatal(err)
}
subscribe()
// To stop for good: closing.Store(true), then client.Close()
select {}
}With the default broker, messages published while a client is disconnected are not delivered to it later. See Running in production for what a reconnect does and does not restore.
Subscribing to topics
Subscribe, Unsubscribe and the filter methods return an error for local failures only (not connected, encode failures). They are fire-and-forget: a nil error means the frame was sent, not that kraken accepted it. Refusals arrive on OnError; see Error handling.
client := nolag.New("dev-token-alice", nolag.Options{URL: "ws://localhost:8080/ws"})
if err := client.Connect(); err != nil {
log.Fatal(err)
}
room := client.SetApp("demo").SetRoom("general")
handler := func(data any, meta nolag.MessageMeta) {
fmt.Printf("Message: %v (filter %q)\n", data, meta.Filter)
}
// Basic subscription
if err := room.Subscribe("messages", handler); err != nil {
fmt.Printf("Subscribe failed: %v\n", err)
}
// With options (QoS, load balancing, filters)
err := room.Subscribe("messages", handler, nolag.SubscribeOptions{
QoS: nolag.QoSLevel(nolag.QoSAtLeastOnce),
LoadBalance: nolag.Bool(true),
LoadBalanceGroup: "workers",
Filters: []any{"priority:high", "region:us"},
})
if err != nil {
fmt.Printf("Subscribe failed: %v\n", err)
}
// Client-level equivalent: the full app/room/topic path
err = client.Subscribe("demo/general/messages", handler)
// Unsubscribe
if err := room.Unsubscribe("messages"); err != nil {
fmt.Printf("Unsubscribe failed: %v\n", err)
}Each topic has one handler: subscribing again replaces it, and kraken replaces the old subscription. Topic messages are delivered only to the handler passed to Subscribe; room.On registers a named client event handler and never receives topic messages.
Publishing messages
client := nolag.New("dev-token-alice", nolag.Options{URL: "ws://localhost:8080/ws"})
if err := client.Connect(); err != nil {
log.Fatal(err)
}
room := client.SetApp("demo").SetRoom("general")
// Publish any MessagePack-encodable data (maps, structs, strings, numbers)
if err := room.Emit("messages", map[string]any{"text": "Hello!"}); err != nil {
fmt.Printf("Emit failed: %v\n", err)
}
// With options
err := room.Emit("messages", map[string]any{"online": true}, nolag.EmitOptions{
QoS: nolag.QoSLevel(nolag.QoSAtLeastOnce), // Override the default QoS
Retain: true, // Keep the last message for later subscribers
Filter: "priority:high", // Route to subscribers filtering on this value
Echo: nolag.Bool(false), // Do not deliver this message back to this connection
})
if err != nil {
fmt.Printf("Emit failed: %v\n", err)
}
// Client-level equivalent: the full path
err = client.Emit("demo/general/messages", map[string]any{"text": "Hello!"})You receive your own messages. kraken delivers a publish to every subscriber of the topic, including the connection that sent it. Set Echo: nolag.Bool(false) to have kraken drop it for the sending connection only; another connection of the same actor still receives it.
A payload may be at most 921,600 bytes once packed, and kraken limits each connection to 50 publishes per second. The Go SDK has no publish acknowledgement; both limits are reported through OnError.
Filters
client := nolag.New("dev-token-alice", nolag.Options{URL: "ws://localhost:8080/ws"})
if err := client.Connect(); err != nil {
log.Fatal(err)
}
room := client.SetApp("demo").SetRoom("general")
handler := func(data any, meta nolag.MessageMeta) {
fmt.Printf("Order (filter %s): %v\n", meta.Filter, data)
}
// Subscribe with initial filters: strings are OR, []string items are AND groups
err := room.Subscribe("messages", handler, nolag.SubscribeOptions{
Filters: []any{"region:us", "status:pending"},
})
// Replace all filters (an empty slice receives everything again)
err = room.SetFilters("messages", []any{"region:eu", "status:shipped"})
// Add to or remove from the current set
err = room.AddFilters("messages", []string{"status:delivered"})
err = room.RemoveFilters("messages", []string{"status:shipped"})
// Publish with a filter value
err = room.Emit("messages", map[string]any{"id": 42}, nolag.EmitOptions{
Filter: "region:us",
})
if err != nil {
fmt.Println(err)
}Filters are routing, not privacy. A subscription without filters receives every message on the topic, filtered publishes included. A subscription with filters receives only publishes whose filter matches. At most 100 filters per topic, and values must not contain /, #, + or |. See Filters. Remember that an automatic reconnect drops filters (see above).
Connection events
client := nolag.New("dev-token-alice", nolag.Options{URL: "ws://localhost:8080/ws"})
// Inside every successful Connect, including automatic reconnects
client.On("connected", func(args ...any) {
fmt.Println("Connected")
})
client.On("disconnected", func(args ...any) {
fmt.Println("Disconnected")
})
// args[0] is the attempt number
client.On("reconnecting", func(args ...any) {
fmt.Printf("Reconnecting, attempt %d\n", args[0].(int))
})
// Broker errors, typed (see Error handling)
client.OnError(func(err *nolag.ServerError) {
fmt.Printf("Error: %v\n", err)
})
// The app's hydration webhook response, once per subscribe.
// args[0] is the bare topic name, for example "messages"; args[1] is the data
client.On("hydration", func(args ...any) {
topic := args[0].(string)
fmt.Printf("Initial state for %s: %v\n", topic, args[1])
})
// Remove every handler for an event
client.Off("hydration")
if err := client.Connect(); err != nil {
log.Fatal(err)
}
if client.Status() == nolag.StatusConnected {
fmt.Println("Connected as", client.ActorID(), "protocol", client.ProtocolVersion())
}Most event handlers run on the SDK's read loop (connected runs inside Connect), so do not block in them. See Webhooks for configuring hydration.
Presence
Presence belongs to a room. Set it through the room context or pass the room slug to SetPresence; presence events arrive on the client:
client := nolag.New("dev-token-alice", nolag.Options{URL: "ws://localhost:8080/ws"})
// Presence events are client-level: args[0] is a nolag.ActorPresence
client.On("presence:join", func(args ...any) {
actor := args[0].(nolag.ActorPresence)
fmt.Printf("%s joined with %v\n", actor.ActorTokenID, actor.Presence)
})
client.On("presence:update", func(args ...any) {
actor := args[0].(nolag.ActorPresence)
fmt.Printf("%s updated: %v\n", actor.ActorTokenID, actor.Presence)
})
client.On("presence:leave", func(args ...any) {
actor := args[0].(nolag.ActorPresence)
fmt.Printf("%s left\n", actor.ActorTokenID)
})
if err := client.Connect(); err != nil {
log.Fatal(err)
}
room := client.SetApp("demo").SetRoom("general")
// Set presence in the room
if err := room.SetPresence(map[string]any{"status": "online"}); err != nil {
fmt.Printf("SetPresence failed: %v\n", err)
}
// Or name the room slug explicitly
if err := client.SetPresence(map[string]any{"status": "online"}, "general"); err != nil {
fmt.Printf("SetPresence failed: %v\n", err)
}
// The room's list from kraken, by room slug
present, err := client.GetPresence("general")
if err == nil {
for _, actor := range present {
fmt.Printf("%s: %v\n", actor.ActorTokenID, actor.Presence)
}
}Things to know:
- A connection is present in one room at a time: setting presence in another room leaves the first. You also receive your own
presence:joinandpresence:updateevents. - kraken resolves the room by its slug in the actor's grants and silently ignores presence for a room it cannot resolve. See Presence.
GetPresencefillsActorTokenIDandPresenceon each entry. The Go SDK keeps no local presence cache.- Presence is not re-sent after an automatic reconnect; set it again in a
connectedhandler.
Lobbies
A lobby lets one client watch presence across several rooms:
client := nolag.New("dev-token-alice", nolag.Options{URL: "ws://localhost:8080/ws"})
if err := client.Connect(); err != nil {
log.Fatal(err)
}
// The actor needs a grant for the lobby (see Lobbies)
lobby := client.SetApp("demo").SetLobby("online")
// A snapshot keyed by room slug, then actor id
snapshot, err := lobby.Subscribe()
if err != nil {
log.Fatal(err)
}
fmt.Println("Rooms:", len(snapshot))
lobby.On("presence:join", func(event nolag.LobbyPresenceEvent) {
fmt.Printf("%s joined %s: %v\n", event.ActorID, event.RoomID, event.Data)
})
lobby.On("presence:leave", func(event nolag.LobbyPresenceEvent) {
fmt.Printf("%s left %s\n", event.ActorID, event.RoomID)
})
latest, _ := lobby.FetchPresence()
fmt.Println(len(latest))
lobby.Unsubscribe()A lobby the actor may not watch is refused with not_authorized on OnError, and Subscribe then returns a timeout error after 10 seconds. See Lobbies for how kraken decides which rooms a lobby covers.
Error handling
Errors reach you through two separate channels, and confusing them is the most common reason a Go client appears to do nothing:
- Returned errors come from local, synchronous problems: not connected, encode failures, timeouts.
- Broker errors are asynchronous.
SubscribeandEmitare fire-and-forget, so a refused subscription or publish is reported later throughOnError, not as a return value.
Register OnError before calling Connect.
client := nolag.New("dev-token-alice", nolag.Options{URL: "ws://localhost:8080/ws"})
client.OnError(func(err *nolag.ServerError) {
// err also satisfies the error interface
log.Printf("nolag: %v", err)
switch err.Name {
case "unknown_topic":
log.Printf("no grant covers %s", err.Topic)
case "rate_limit_exceeded", "message_too_large":
log.Printf("publish refused on %s", err.Topic)
}
})
if err := client.Connect(); err != nil {
// errors.Is(err, nolag.ErrAuthFailed) when kraken refused the token
log.Fatal(err)
}ServerError carries the whole error frame:
| Field | Type | Description |
|---|---|---|
Code | int | Numeric code, for example 42940. Zero when kraken sent none |
Name | string | Machine-readable name, for example unknown_topic. Always set |
Topic | string | The topic the error refers to, when topic-scoped |
Hint | string | A remediation hint, when kraken provides one |
MsgRef | string | The publish the error responds to, when applicable |
Name | Code | Cause |
|---|---|---|
unknown_topic | 42940 | Subscribe or publish to an address the actor has no grant for |
rate_limit_exceeded | 42910 | More than 50 publishes in one second on this connection |
message_too_large | 42930 | Payload over 921,600 bytes once packed |
not_authorized | 0 | A lobby the actor may not watch |
not_subscribed | 0 | SetFilters on a topic this connection is not subscribed to |
The SDK requests protocol version 2, and kraken agrees to it, so Code and Hint are populated; client.ProtocolVersion() reports the negotiated version. The sentinel errors for returned errors are nolag.ErrNotConnected, nolag.ErrAuthFailed and nolag.ErrTimeout; compare with errors.Is.
QoS
| Constant | Level | Meaning |
|---|---|---|
QoSAtMostOnce | 0 | At most once on the broker hop |
QoSAtLeastOnce | 1 | At least once on the broker hop (default) |
QoSExactlyOnce | 2 | Exactly once on the broker hop |
QoS is not an end-to-end guarantee. kraken validates the level and hands it to its broker backend. The built-in broker delivers in memory and does not use it; with an external MQTT broker it applies to kraken's hop to that broker. The WebSocket leg has no resend at any level, so deduplicate with an idempotency key if a message must not be applied twice. See QoS.
Load balancing
Without load balancing every subscriber receives every message. With it, subscriptions that share a group each get a share: every message goes to exactly one member.
processTask := func(data any, meta nolag.MessageMeta) {
fmt.Printf("Processing: %v\n", data)
}
// For every subscription on this client. Connection-level settings
// also survive automatic reconnects
worker := nolag.New("dev-token-alice", nolag.Options{
URL: "ws://localhost:8080/ws",
LoadBalance: true,
LoadBalanceGroup: "task-workers",
})
if err := worker.Connect(); err != nil {
log.Fatal(err)
}
worker.SetApp("demo").SetRoom("general").Subscribe("messages", processTask)
// Or per subscription
client := nolag.New("dev-token-bob", nolag.Options{URL: "ws://localhost:8080/ws"})
if err := client.Connect(); err != nil {
log.Fatal(err)
}
err := client.SetApp("demo").SetRoom("general").Subscribe("messages", processTask, nolag.SubscribeOptions{
LoadBalance: nolag.Bool(true),
LoadBalanceGroup: "task-workers",
})
if err != nil {
fmt.Printf("Subscribe failed: %v\n", err)
}Without a group name, the group is the actor's id. kraken scopes group names to the project and app, so the same name in two apps never collides. For workers that must stay load-balanced across reconnects, prefer the connection-level options.
Types
| Type | Purpose |
|---|---|
nolag.Client | The client, from nolag.New(token, opts) |
nolag.Options | Connection options |
nolag.SubscribeOptions | QoS, LoadBalance, LoadBalanceGroup, Filters |
nolag.EmitOptions | QoS, Retain, Echo, Filter, Filters |
nolag.App, nolag.Room, nolag.Lobby | Contexts from SetApp, SetRoom and SetLobby |
nolag.MessageMeta | MsgID and Filter of a received message |
nolag.MessageHandler | func(data any, meta MessageMeta) |
nolag.EventHandler | func(args ...any) |
nolag.ActorPresence | A presence entry |
nolag.LobbyPresenceEvent | LobbyID, RoomID, ActorID, Data |
nolag.LobbyPresenceState | Snapshot keyed by room, then actor |
nolag.ServerError, nolag.ErrorHandler | Broker errors and their handler |
nolag.ConnectionStatus | StatusDisconnected, StatusConnecting, StatusConnected, StatusReconnecting |
nolag.QoS | QoSAtMostOnce, QoSAtLeastOnce, QoSExactlyOnce |
nolag.Bool, nolag.QoSLevel | Pointer helpers for the optional fields |
Requirements
- Go 1.21+
- Its dependencies (gorilla/websocket, vmihailenco/msgpack/v5 and pion/webrtc/v3) are fetched by
go get