JavaScript SDK

The official NoLag SDK for JavaScript and TypeScript. Full TypeScript support with comprehensive type definitions.

Installation

# npm
npm install @nolag/js-sdk

# yarn
yarn add @nolag/js-sdk

# pnpm
pnpm add @nolag/js-sdk

Quick Start

import { NoLag } from '@nolag/js-sdk'

// Create client with access token
const client = NoLag('your-access-token')

// Connect to NoLag
await client.connect()

// Scope to an app and a room. APP_SLUG is the slug returned when you
// created the app (slugs carry a random suffix, e.g. 'chat-a3f9').
const room = client.setApp(APP_SLUG).setRoom('general')

// Subscribe to a topic in the room
room.subscribe('messages')

// Listen for messages
room.on('messages', (data, meta) => {
  console.log('Received:', data)
})

// Publish a message
room.emit('messages', { text: 'Hello!' })

Topics live inside rooms. The only hierarchy is app/room/topic: the room context above addresses chat-a3f9/general/messages. Topic names are single tokens ([a-zA-Z0-9_:-], no /). Rooms never exist implicitly: create them via the REST API or the portal first, or subscribing returns unknown_topic (42940). App slugs always get a random 4-hex suffix, so read slug from the create response and pass that to setApp().

Browser Authentication (Token Provider)

In a browser, pass an async token provider instead of an access token string. The SDK calls it on every connect and reconnect, so each attempt authenticates with a fresh short-lived client token minted by your backend:

import { NoLag } from '@nolag/js-sdk'

const client = NoLag(async () => {
  const res = await fetch('/api/nolag-token')
  const { token } = await res.json()
  return token
})

await client.connect()

Import from @nolag/js-sdk, the same entry point you use on the server. The package declares a browser export condition, so bundlers such as Vite, webpack, Next.js, and Rollup resolve the browser build automatically. There is no @nolag/js-sdk/browser subpath to import.

When the resolved token is a JWT, the SDK renews it in place shortly before expiry: a fresh token is minted by your provider and applied over the live connection, so nothing disconnects and token rotation is invisible to your application code. Older brokers fall back to a quick reconnect with server-side subscription restore.

Connection Options

const client = NoLag('your-access-token', {
  // Connection
  url: 'wss://broker.nolag.app/ws',  // WebSocket URL (default)
  reconnect: true,                    // Auto-reconnect (default: true)
  reconnectInterval: 5000,            // Initial reconnect delay ms (default: 5000)

  // Messaging
  qos: 1,                             // Default QoS level: 0, 1, or 2 (default: 1)

  // Load Balancing
  loadBalance: false,                 // Enable load balancing (default: false)
  loadBalanceGroup: 'worker-pool',    // Load balance group name

  // Worker identity (agent and orchestrator actors only)
  clientId: 'worker-1',               // Names this client instance

  // Browser-specific
  disconnectOnHidden: false,          // Disconnect when tab hidden (default: false)
  heartbeatInterval: 30000,           // Heartbeat interval ms (default: 30000, 0 to disable)

  // Debugging
  debug: false,                       // Enable debug logging (default: false)
})

Connection Management

// Connect to NoLag
await client.connect()

// Check connection status
console.log(client.status)     // 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
console.log(client.connected)  // true or false

// Disconnect (prevents auto-reconnect)
client.disconnect()

// Access client info (available after connect)
console.log(client.actorId)    // Your actor token ID
console.log(client.actorType)  // 'device' | 'user' | 'service' | 'session' | 'agent' | 'orchestrator' | 'observer'
console.log(client.projectId)  // Project ID

Client Events

// Connection established
client.on('connect', () => {
  console.log('Connected to NoLag')
})

// Connection lost
client.on('disconnect', (reason) => {
  console.log('Disconnected:', reason)
})

// Reconnection starting
client.on('reconnect', () => {
  console.log('Attempting to reconnect...')
})

// Error occurred: NoLagServerError | NoLagEncodeError | Error (see Error Handling)
client.on('error', (error) => {
  console.error('Error:', error.message)
})

// Hydration: with a hydration webhook configured on the app, the broker
// sends the webhook's response body once per subscribe.
// `topic` is the bare topic name, e.g. 'messages'.
client.on('hydration', ({ topic, data }) => {
  console.log(`Initial state for ${topic}:`, data)
})

// Replay events: fired around a replay of queued messages. Replay only
// happens for load-balanced worker groups whose actors hold a persistent
// session. An ordinary reconnect restores subscriptions and replays nothing.
client.on('replay:start', (event) => {
  // event: { count, oldestTimestamp?, newestTimestamp? }
  console.log(`Replaying ${event.count} queued messages...`)
})

client.on('replay:end', (event) => {
  // event: { replayed }
  console.log(`Replay complete: ${event.replayed} messages`)
})

// During replay, individual messages have meta.isReplay = true
room.on('messages', (data, meta) => {
  if (meta.isReplay) {
    console.log('Replayed message:', data)
  } else {
    console.log('Live message:', data)
  }
})

See Replay for when messages are queued, and Webhooks for configuring hydration.

Subscribing to Topics

Call subscribe() after connect() has resolved: on a disconnected client the callback receives a Not connected error and nothing is sent. Subscriptions are persisted server-side and restored on reconnect.

const room = client.setApp(APP_SLUG).setRoom('general')

// Basic subscription
room.subscribe('messages')

// With options
room.subscribe('messages', {
  qos: 2,                        // Override default QoS
  loadBalance: true,             // Enable load balancing for this topic
  loadBalanceGroup: 'workers',   // Specific load balance group
})

// With acknowledgment callback
room.subscribe('messages', (error) => {
  if (error) {
    console.error('Subscribe failed:', error)
  } else {
    console.log('Subscribed successfully')
  }
})

// Client-level equivalent: pass the full app/room/topic path yourself
client.subscribe(`${APP_SLUG}/general/messages`)

Listening for Messages

// Listen to a specific topic in the room
room.on('messages', (data, meta) => {
  console.log('Data:', data)
  console.log('Message ID:', meta.msgId)     // Unique message ID
  console.log('Is replay:', meta.isReplay)   // true if replayed from a persistent session queue
  console.log('Filter:', meta.filter)        // Filter value (if published with one)
})

// Listen to all subscribed topics. `topic` is the full app/room/topic path
client.onAny((topic: string, data: unknown, meta: MessageMeta) => {
  console.log(`[${topic}]`, data, meta)
})

// Remove a specific handler
const handler = (data, meta) => console.log(data)
room.on('messages', handler)
room.off('messages', handler)

// Remove all handlers for a topic
room.off('messages')

// Client-level equivalent: the full path
client.on(`${APP_SLUG}/general/messages`, (data, meta) => {
  console.log(data)
})

Publishing Messages

// Basic publish
room.emit('messages', { text: 'Hello!' })

// With options
room.emit('messages', { text: 'Important' }, {
  qos: 2,           // Override default QoS
  filter: 'vip',    // Route to subscribers with this filter
  echo: false,      // Per-connection flag; see the note below
})

// With acknowledgment callback (the broker's publish ack, protocol v2)
room.emit('messages', { text: 'Hello' }, (error) => {
  if (error) {
    console.error('Publish failed:', error)
  } else {
    console.log('Message sent')
  }
})

// With options and callback
room.emit('messages', { text: 'Hello' }, { qos: 2 }, (error) => {
  if (error) console.error(error)
})

// Client-level equivalent: the full path
client.emit(`${APP_SLUG}/general/messages`, { text: 'Hello!' })

Publishers never receive their own messages. The broker drops a message before delivering it to the actor that published it, whatever echo is set to. Append your own message to the UI locally. echo: false only adds a per-connection drop for the rare case of two connections sharing one actor token.

Room Presence

Presence is scoped to rooms. Set it through the room context; presence events arrive on the client:

// Join a room and set presence
const room = client.setApp(APP_SLUG).setRoom('general')

room.setPresence({
  username: 'Alice',
  status: 'online',
  avatar: '/img/alice.png'
})

// Get all actors in this room (from local cache)
const actors = room.getPresence()
console.log('In room:', Object.keys(actors).length)

// Fetch presence list from server (async)
const freshList = await room.fetchPresence()

// Presence events are client-level. Each carries { actorTokenId, presence }
client.on('presence:join', (actor) => {
  console.log(`${actor.presence.username} joined`)
})

client.on('presence:leave', (actor) => {
  console.log(`${actor.presence.username} left`)
})

client.on('presence:update', (actor) => {
  console.log(`${actor.presence.username} updated status`)
})

Presence events are not room events. room.on('presence:join') registers a topic handler for app/room/presence:join and never fires; listen with client.on('presence:join'). client.setPresence() without a room is deprecated and is not broadcast; use room.setPresence().

Lobbies (Multi-Room Presence)

Lobbies let you observe presence across multiple rooms. Use them for dashboards and monitoring interfaces:

// Subscribe to a lobby to observe all rooms
const lobby = client.setApp(APP_SLUG).setLobby('active-trips')

// Subscribe returns a snapshot of current presence
const snapshot = await lobby.subscribe()
// snapshot: { roomId -> { actorId -> presenceData } }
console.log('Active trips:', Object.keys(snapshot).length)

// Listen for presence events (includes room context)
lobby.on('presence:join', (event) => {
  // event: { lobbyId, roomId, actorId, data }
  console.log(`${event.actorId} joined room ${event.roomId}`)
  addToMap(event.roomId, event.actorId, event.data)
})

lobby.on('presence:update', (event) => {
  updateOnMap(event.roomId, event.actorId, event.data)
})

lobby.on('presence:leave', (event) => {
  removeFromMap(event.roomId, event.actorId)
})

// Fetch fresh presence at any time
const freshPresence = await lobby.fetchPresence()

// Unsubscribe when done
lobby.unsubscribe()

Read-only: Lobbies are for observation only. Actors set presence on rooms, not lobbies. See Lobbies documentation for details.

Fluent API (Scoped Pub/Sub)

Use the fluent API to scope subscriptions and messages to a specific app and room:

// Create a room context. APP_SLUG is the suffixed slug from the create response
const room = client.setApp(APP_SLUG).setRoom('general')

// Subscribe (topic is auto-prefixed, e.g. to 'chat-a3f9/general/messages')
room.subscribe('messages')

// Listen for messages
room.on('messages', (data, meta) => {
  console.log('Message:', data)
})

// Publish (also auto-prefixed)
room.emit('messages', { text: 'Hello room!' })

// Get the full topic prefix
console.log(room.prefix) // e.g. 'chat-a3f9/general'

// Unsubscribe
room.unsubscribe('messages')

Unsubscribing

// Unsubscribe from a topic in the room
room.unsubscribe('messages')

// With callback
room.unsubscribe('messages', (error) => {
  if (error) {
    console.error('Unsubscribe failed:', error)
  }
})

// Client-level equivalent: the full path
client.unsubscribe(`${APP_SLUG}/general/messages`)

Topic Filters

Filters let you narrow message delivery to specific entities within a topic. Instead of receiving all updates on a topic, subscribe with filters to only get messages for the entities you care about.

const room = client.setApp(APP_SLUG).setRoom('ops')

// Subscribe with filters: only receive updates for these bookings
room.subscribe('bookings', {
  filters: ['booking_1', 'booking_2']
})

// Listen for filtered messages
room.on('bookings', (data, meta) => {
  console.log('Booking update:', data)
  console.log('Filter:', meta.filter)  // e.g. 'booking_1'
})

// Publish to a specific filter
room.emit('bookings', { status: 'confirmed' }, {
  filter: 'booking_1'  // Reaches subscribers with this filter, plus subscribers with no filters
})

Dynamic Filter Management

Change filters at any time without resubscribing:

// Replace all filters for a topic
room.setFilters('bookings', ['booking_3', 'booking_4'])

// Add filters to existing set
room.addFilters('bookings', ['booking_5'])

// Remove specific filters
room.removeFilters('bookings', ['booking_3'])

// Client-level equivalents take the full path
client.setFilters(`${APP_SLUG}/ops/bookings`, ['booking_2', 'booking_3'])
client.addFilters(`${APP_SLUG}/ops/bookings`, ['booking_4'])
client.removeFilters(`${APP_SLUG}/ops/bookings`, ['booking_2'])

No filters = wildcard: Subscribing without filters receives all messages on the topic, including filtered publishes. Subscribing with filters only receives messages published with a matching filter. Messages published without a filter are only delivered to wildcard (no-filter) subscribers. Each filter is its own routing key, not a privacy boundary. Max 100 filters per topic; values must not contain /, #, + or |. See Filters.

Quality of Service (QoS)

// Set the default QoS for all messages (default: 1)
const client = NoLag(token, { qos: 1 })
const room = client.setApp(APP_SLUG).setRoom('general')

// QoS 0: at most once on the broker hop (fire and forget)
room.emit('telemetry', data, { qos: 0 })

// QoS 1: at least once on the broker hop (default)
room.emit('orders', order, { qos: 1 })

// QoS 2: deduplicated on the broker hop
room.emit('payments', payment, { qos: 2 })

QoS is a broker-hop setting, not an end-to-end guarantee. The level is validated (0, 1 or 2) and passed to the broker's internal MQTT hop. The WebSocket leg has an optional publish ack (surfaced as the emit callback) and no resend, so no level is an end-to-end delivery guarantee. Deduplicate with an idempotency key if a message must not be applied twice. See QoS.

Load Balancing

Load balancing is opt-in. Without it every subscriber receives every message. With it, subscribers that share a loadBalanceGroup form a group and each message is delivered to one member of the group:

// Enable for all subscriptions
const client = NoLag(token, {
  loadBalance: true,
  loadBalanceGroup: 'worker-pool-1'
})

// Or per-subscription
const room = client.setApp(APP_SLUG).setRoom('image-processing')
room.subscribe('jobs', {
  loadBalance: true,
  loadBalanceGroup: 'job-workers'
})

// Only ONE client in the group receives each message

Workers whose actor type holds a persistent session (agent, orchestrator) also have messages queued while they are away; see Replay.

REST API Client

Manage apps, rooms, actors, and scopes programmatically:

import { NoLagApi } from '@nolag/js-sdk'
import type { PaginatedResult, App } from '@nolag/js-sdk'

const api = new NoLagApi('your-api-key', {   // nlg_live_<12hex>.<secret>
  baseUrl: 'https://api.nolag.app/v1',  // Optional
  timeout: 30000,                        // Optional
})

// Apps: list() returns PaginatedResult<App>
const result: PaginatedResult<App> = await api.apps.list()
console.log(result.data)                  // App[]
console.log(result.pagination.total)      // Total count
console.log(result.pagination.page)       // Current page
console.log(result.pagination.pageCount)  // Total pages

const singleApp = await api.apps.get(appId)            // Get by ID
const app = await api.apps.create({                     // Create
  name: 'My App',
  topics: ['messages'],  // Topics actors may address; without them every subscribe is unknown_topic
})
console.log(app.slug)                                   // e.g. 'my-app-a3f9': pass this to setApp()
await api.apps.update(app.appId, { name: 'Updated' })  // Update
await api.apps.delete(app.appId)                        // Delete

// Rooms (plain array). Rooms must exist before clients can subscribe
const rooms = await api.rooms.list(appId)                // List rooms in an app
const singleRoom = await api.rooms.get(appId, roomId)    // Get by ID
const room = await api.rooms.create(appId, {             // Create
  name: 'General', slug: 'general'
})
await api.rooms.ensure(appId, {                          // Create if missing (idempotent);
  name: 'Order 42', slug: 'order-42'                     // needs config.autoProvisionRooms=true on the app
})
await api.rooms.update(appId, room.roomId, {             // Update
  name: 'Updated Room'
})
await api.rooms.delete(appId, room.roomId)               // Delete

// Actors (plain array)
const actors = await api.actors.list()                   // List all actors
const singleActor = await api.actors.get(actorTokenId)   // Get by ID
const actor = await api.actors.create({                  // Create
  name: 'Device 1',
  actorType: 'device'  // 'device' | 'user' | 'service' | 'session' | 'agent' | 'orchestrator' | 'observer'
})
console.log('Access Token:', actor.accessToken)  // Save this! Only shown once

await api.actors.update(actor.actorTokenId, { name: 'Updated Device' })
await api.actors.delete(actor.actorTokenId)

// Scopes: list() is paginated like apps
const scopes = await api.scopes.list()
console.log(scopes.data, scopes.pagination.total)

TypeScript Support

The SDK includes full TypeScript definitions:

import { NoLag, NoLagApi, NoLagServerError, NoLagEncodeError } from '@nolag/js-sdk'
import type {
  NoLagOptions,
  ConnectionStatus,
  ActorType,
  MessageMeta,
  MessageHandler,
  ErrorHandler,
  HydrationEvent,
  ActorPresence,
  PresenceData,
  LobbyPresenceEvent,
  LobbyPresenceState,
  ReplayStartEvent,
  ReplayEndEvent,
  QoS,
  SubscribeOptions,
  EmitOptions,
  RoomContext,
  LobbyContext,
  PaginatedResult,
} from '@nolag/js-sdk'

const client = NoLag('your-access-token')
const room: RoomContext = client.setApp(APP_SLUG).setRoom('general')

// Type your message data. MessageHandler<T> is (data: T, meta: MessageMeta) => void
interface ChatMessage {
  text: string
  sender: string
  timestamp: number
}

room.on<ChatMessage>('messages', (data, meta) => {
  console.log(`[${data.sender}]: ${data.text}`)
})

// Inline payload type
room.on<{ text: string }>('messages', (data) => data.text)

// Or type the handler itself
const onMessage: MessageHandler<ChatMessage> = (data, meta) => {
  console.log(data.text, meta.msgId)
}
room.on('messages', onMessage)

// ActorType: 'device' | 'user' | 'service' | 'session' | 'agent' | 'orchestrator' | 'observer'
const kind: ActorType | null = client.actorType

// ErrorHandler: (error: NoLagServerError | NoLagEncodeError | Error) => void
const onError: ErrorHandler = (error) => {
  if (error instanceof NoLagServerError) console.error(error.code, error.hint)
}
client.on('error', onError)

// HydrationEvent: { topic: string, data: unknown }
client.on('hydration', (event: HydrationEvent) => console.log(event.topic, event.data))

Error Handling

The error event receives NoLagServerError | NoLagEncodeError | Error. Narrow with instanceof. Broker-side failures (a room that does not exist, a topic the actor cannot write) arrive as NoLagServerError with error (the machine name), code, topic and hint. Register the handler before connect() so nothing is missed during the handshake.

import { NoLag, NoLagApiError, NoLagServerError, NoLagEncodeError } from '@nolag/js-sdk'

const client = NoLag('your-access-token')
const room = client.setApp(APP_SLUG).setRoom('general')

client.on('error', (error) => {
  if (error instanceof NoLagServerError) {
    // A structured frame from the broker (protocol v2)
    console.error(error.error, error.code, error.topic, error.hint)
    if (error.error === 'unknown_topic') {
      // 42940: the room has not been created. Create it via the REST API.
    }
  } else if (error instanceof NoLagEncodeError) {
    // The payload could not be msgpack-encoded (class instances, cycles)
    console.error('Cannot encode payload for', error.op, error.topic)
  } else {
    console.error('Transport error:', error.message)
  }
})

// Subscribe/emit callbacks
room.subscribe('messages', (error) => {
  if (error) {
    console.error('Subscribe failed:', error.message)
  }
})

room.emit('messages', data, (error) => {
  if (error) {
    console.error('Emit failed:', error.message)
  }
})

// REST API errors
try {
  await api.apps.get('invalid-id')
} catch (error) {
  if (error instanceof NoLagApiError) {
    console.error('API Error:', error.statusCode, error.message)
  }
}

Browser Usage

<script type="module">
  import { NoLag } from 'https://unpkg.com/@nolag/js-sdk/dist/browser.js'

  const client = NoLag('your-access-token')
  await client.connect()

  // 'your-app-slug' is the suffixed slug returned when you created the app
  const room = client.setApp('your-app-slug').setRoom('general')
  room.subscribe('updates')
  room.on('updates', (data) => {
    document.getElementById('output').textContent = JSON.stringify(data)
  })
</script>

Complete Example

import { NoLag, NoLagApi, NoLagServerError } from '@nolag/js-sdk'

// Server side, once: create an app, a room in it, and an actor
const api = new NoLagApi(process.env.NOLAG_API_KEY)  // nlg_live_...
const app = await api.apps.create({ name: 'Chat', topics: ['messages'] })
console.log(app.slug)  // e.g. 'chat-a3f9': the slug clients pass to setApp()
await api.rooms.create(app.appId, { name: 'General', slug: 'general' })
const actor = await api.actors.create({ name: 'chat-client', actorType: 'user' })
console.log(actor.accessToken)  // shown once; hand it to the client

// Client side: connect with the actor's access token
const client = NoLag(actor.accessToken, {
  reconnect: true,
  qos: 1
})

client.on('error', (error) => {
  if (error instanceof NoLagServerError) {
    console.error(error.error, error.code, error.hint)
  } else {
    console.error('Error:', error)
  }
})

client.on('disconnect', (reason) => {
  console.log('Disconnected:', reason)
})

client.on('presence:join', (member) => {
  console.log(`${member.presence.username} joined`)
})

await client.connect()
console.log('Connected as:', client.actorId)

// Join the room: subscribe, set presence, listen
const room = client.setApp(app.slug).setRoom('general')
room.subscribe('messages')
room.setPresence({ username: 'Alice', status: 'online' })

room.on('messages', (data, meta) => {
  console.log('Message:', data)
})

// Send a message. You will not receive it yourself, so render it locally
room.emit('messages', { text: 'Hello everyone!' })

Next Steps