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-sdk

Quick 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 SubscribeOptions you originally passed. Filters, per-subscription QoS and per-subscription load balancing are dropped; connection-level Options (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 connected handler, which runs inside every successful Connect, 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:join and presence:update events.
  • kraken resolves the room by its slug in the actor's grants and silently ignores presence for a room it cannot resolve. See Presence.
  • GetPresence fills ActorTokenID and Presence on each entry. The Go SDK keeps no local presence cache.
  • Presence is not re-sent after an automatic reconnect; set it again in a connected handler.

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. Subscribe and Emit are fire-and-forget, so a refused subscription or publish is reported later through OnError, 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:

FieldTypeDescription
CodeintNumeric code, for example 42940. Zero when kraken sent none
NamestringMachine-readable name, for example unknown_topic. Always set
TopicstringThe topic the error refers to, when topic-scoped
HintstringA remediation hint, when kraken provides one
MsgRefstringThe publish the error responds to, when applicable
NameCodeCause
unknown_topic42940Subscribe or publish to an address the actor has no grant for
rate_limit_exceeded42910More than 50 publishes in one second on this connection
message_too_large42930Payload over 921,600 bytes once packed
not_authorized0A lobby the actor may not watch
not_subscribed0SetFilters 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

ConstantLevelMeaning
QoSAtMostOnce0At most once on the broker hop
QoSAtLeastOnce1At least once on the broker hop (default)
QoSExactlyOnce2Exactly 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

TypePurpose
nolag.ClientThe client, from nolag.New(token, opts)
nolag.OptionsConnection options
nolag.SubscribeOptionsQoS, LoadBalance, LoadBalanceGroup, Filters
nolag.EmitOptionsQoS, Retain, Echo, Filter, Filters
nolag.App, nolag.Room, nolag.LobbyContexts from SetApp, SetRoom and SetLobby
nolag.MessageMetaMsgID and Filter of a received message
nolag.MessageHandlerfunc(data any, meta MessageMeta)
nolag.EventHandlerfunc(args ...any)
nolag.ActorPresenceA presence entry
nolag.LobbyPresenceEventLobbyID, RoomID, ActorID, Data
nolag.LobbyPresenceStateSnapshot keyed by room, then actor
nolag.ServerError, nolag.ErrorHandlerBroker errors and their handler
nolag.ConnectionStatusStatusDisconnected, StatusConnecting, StatusConnected, StatusReconnecting
nolag.QoSQoSAtMostOnce, QoSAtLeastOnce, QoSExactlyOnce
nolag.Bool, nolag.QoSLevelPointer 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

Next steps