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-sdkQuick 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 IDClient 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 messageWorkers 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!' })