JavaScript SDK

@nolag/js-sdk is the NoLag client for browsers and Node.js 18+, written in TypeScript with its type definitions included. It is MIT licensed.

Installation

# npm
npm install @nolag/js-sdk

# yarn
yarn add @nolag/js-sdk

# pnpm
pnpm add @nolag/js-sdk

Versions. This page describes 1.13.0 and 1.14.0, the current release. A few things need 1.14.0, and are marked where they appear: the retain publish option (1.13.0 accepts it but never sends it), the hydration event, and the typed error and message handlers. Install the latest.

Quick Start

This connects to the local kraken from the quickstart as the demo user alice, whose token grants demo/general/#:

import { NoLag } from "@nolag/js-sdk";

const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });

// A room context prefixes topics with app/room, so this is demo/general/*
const room = client.setApp("demo").setRoom("general");

room.on("messages", (data, meta) => {
  console.log("Received:", data);
});

// 'connect' fires after every successful connection, including reconnects,
// so subscriptions made here are re-established after a drop
client.on("connect", () => {
  room.subscribe("messages");
});

await client.connect();

room.emit("messages", { text: "Hello!" });

NoLag is a factory function: call it, do not use new. The url option is required in practice, because 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) on the error event. See Topics and Rooms.

Reconnects and subscriptions

The client reconnects by itself after a dropped connection, but it keeps no list of what you subscribed to, and kraken restores subscriptions only when your auth backend supplies them (the static auth file never does). Treat every connection as starting empty:

  • Register the connect handler before calling connect(). It runs after every successful connection: the first one, each automatic reconnect, a resume after disconnectOnHidden, and a reconnect for a token refresh.
  • Subscribe, with any filters and load-balancing options, inside that handler.
  • Set room presence inside it too. Room presence set with room.setPresence() is not re-sent after a reconnect.

Message handlers registered with on() are local and survive reconnects, so register them once, outside the handler.

import { NoLag } from "@nolag/js-sdk";

const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });
const room = client.setApp("demo").setRoom("general");

room.on("messages", (data) => console.log(data));

client.on("connect", () => {
  room.subscribe("messages", { filters: ["vip"] });
  room.setPresence({ username: "Alice", status: "online" });
});

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

await client.connect();

The retry schedule starts at reconnectInterval (5 seconds by default) and grows by 1.5x per attempt up to 30 seconds. After 10 consecutive failed attempts the client stops trying; call connect() again to start over. 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.

Browser authentication (token provider)

In a browser, pass an async token provider instead of a 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;
  },
  { url: "wss://realtime.example.com/ws" },
);

await client.connect();

When the resolved token is a JWT, the SDK asks the provider for a new one about 30 seconds before it expires and sends it to kraken over the live connection (an in-band reauth), so nothing disconnects. If that fails, it reconnects with the fresh token instead. A JWT passed as a plain string is never refreshed: once it has expired, kraken closes the connection with code 4003 at the next heartbeat.

Import from @nolag/js-sdk in the browser too. The package declares a browser export condition, so Vite, webpack, Next.js and Rollup pick the browser build automatically.

Connection options

import { NoLag } from "@nolag/js-sdk";

const client = NoLag("dev-token-alice", {
  // Connection
  url: "ws://localhost:8080/ws",  // Your kraken's WebSocket URL (always set this)
  reconnect: true,                // Reconnect automatically (default: true)
  reconnectInterval: 5000,        // First retry delay in ms (default: 5000)
  heartbeatInterval: 30000,       // Heartbeat in ms (default: 30000, 0 disables)

  // Messaging
  qos: 1,                         // Default QoS: 0, 1 or 2 (default: 1)
  ackBatchInterval: 0,            // Batch delivery acks for this many ms (default: 0)

  // Load balancing
  loadBalance: false,             // Load-balance every subscription (default: false)
  loadBalanceGroup: "workers",    // Group name (default: the actor's id)

  // Browser
  disconnectOnHidden: false,      // Drop the socket while the tab is hidden (default: false)

  // Debugging
  debug: false,                   // Log protocol traffic (default: false)
});

Keep the heartbeat on: kraken closes a socket that has been idle for 60 seconds.

Three further options exist for specialised setups. lifecycle and network take adapters for foreground/background and reachability signals; browsers get them automatically, and React Native wires them for you. clientId names a client instance and only matters when your auth backend gives the actor a persistent broker session; otherwise kraken ignores it.

Connection management

import { NoLag } from "@nolag/js-sdk";

const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });

// connect() resolves once kraken has accepted the token
await client.connect();

console.log(client.status);          // 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
console.log(client.connected);       // true or false
console.log(client.actorId);         // the actor id from your auth configuration
console.log(client.actorType);       // e.g. 'user', 'device' or 'service'
console.log(client.protocolVersion); // 2 against kraken

// Close the socket and stop reconnecting
client.disconnect();

// Also release lifecycle and network listeners (use when the client goes away for good)
client.destroy();

connect() rejects with the broker's reason when the token is refused, for example access_denied. With reconnect on, the client then keeps retrying in the background on the schedule above, so call disconnect() if you want it to stop.

Client events

import { NoLag } from "@nolag/js-sdk";

const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });

// After every successful connection (first connect and each reconnect)
client.on("connect", () => {
  console.log("Connected");
});

// The connection dropped or was closed
client.on("disconnect", (reason) => {
  console.log("Disconnected:", reason);
});

// A reconnect attempt has been scheduled (not that it succeeded)
client.on("reconnect", () => {
  console.log("Reconnecting...");
});

// Broker refusals, encode failures and transport errors (see Error handling)
client.on("error", (error) => {
  console.error("Error:", error.message);
});

// 1.14.0+: the app's hydration webhook response, once per subscribe.
// `topic` is the bare topic name, for example 'messages'.
client.on("hydration", (event: { topic: string; data: unknown }) => {
  console.log(`Initial state for ${event.topic}:`, event.data);
});

Version 1.13.0 drops hydration frames, so the hydration event needs 1.14.0. See Webhooks for configuring hydration.

Subscribing to topics

Subscribe only on a connected client: on a disconnected one the callback receives a Not connected error and nothing is sent. Subscribing inside the connect handler, as above, takes care of that.

import { NoLag } from "@nolag/js-sdk";

const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });
await client.connect();

const room = client.setApp("demo").setRoom("general");

// Basic subscription
room.subscribe("messages");

// With options
room.subscribe("messages", {
  qos: 2,                       // Override the default QoS
  loadBalance: true,            // Deliver each message to one member of the group
  loadBalanceGroup: "workers",  // Group name
  filters: ["vip"],             // Only messages published with these filters
});

// With an acknowledgement callback: resolved by kraken's 'subscribed'
// frame, or by the error frame that refused the subscription
room.subscribe("messages", (error) => {
  if (error) {
    console.error("Subscribe failed:", error.message);
  } else {
    console.log("Subscribed");
  }
});

// Client-level equivalent: pass the full app/room/topic path yourself
client.subscribe("demo/general/messages");

Subscribing again to a topic you already hold replaces the old subscription, so it is safe to re-run your subscribe code.

Listening for messages

import { NoLag, type MessageMeta } from "@nolag/js-sdk";

const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });
const room = client.setApp("demo").setRoom("general");

// Handlers for one topic in the room
room.on("messages", (data, meta) => {
  console.log("Data:", data);
  console.log("Message id:", meta.msgId);  // set when kraken records messages
  console.log("Filter:", meta.filter);     // the filter the message arrived under, if any
});

// Every topic message on the client. `topic` is the full app/room/topic path
client.onAny((topic: string, data: unknown, meta: MessageMeta) => {
  console.log(`[${topic}]`, data);
});

// Remove one handler, or every handler for a topic
const handler = (data: unknown) => console.log(data);
room.on("messages", handler);
room.off("messages", handler);
room.off("messages");

// Client-level equivalent: the full path
client.on("demo/general/messages", (data) => {
  console.log(data);
});

When kraken records messages (RECORD_MESSAGES, on by default), each message carries a msgId and the SDK acknowledges it automatically. Acknowledgements are recorded by kraken; they do not cause a message to be resent. client.ack(msgId) and client.batchAck(msgIds) exist for manual use.

Publishing messages

import { NoLag } from "@nolag/js-sdk";

const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });
await client.connect();

const room = client.setApp("demo").setRoom("general");

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

// With options
room.emit("messages", { text: "Important" }, {
  qos: 2,          // Override the default QoS
  filter: "vip",   // Route to subscribers filtering on 'vip' (and to unfiltered ones)
  echo: false,     // Do not deliver this message back to this connection
});

// Keep the last message on the topic for later subscribers (sent from 1.14.0)
room.emit("status", { online: true }, { retain: true });

// With an acknowledgement callback: resolved by kraken's 'published' frame
room.emit("messages", { text: "Hello" }, (error) => {
  if (error) console.error("Publish failed:", error.message);
});

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

// Client-level equivalent: the full path
client.emit("demo/general/messages", { text: "Hello!" });

You receive your own messages. kraken delivers a publish to every subscriber of the topic, including the connection that sent it. Pass echo: false to have kraken drop it for the sending connection only; another connection of the same actor still receives it. (The echo doc comment shipped in 1.14.0 says the publisher never receives its own message. That described the retired hosted broker, not kraken.)

A payload must be MessagePack-encodable plain data, and at most 921,600 bytes once packed. kraken also limits each connection to 50 publishes per second. See Error handling for the errors these produce.

Room presence

Presence belongs to a room. Set it through the room context; presence events arrive on the client:

import { NoLag } from "@nolag/js-sdk";

const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });
const room = client.setApp("demo").setRoom("general");

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

client.on("presence:update", (actor) => {
  console.log(`${actor.actorTokenId} updated`, actor.presence);
});

client.on("presence:leave", (actor) => {
  console.log(`${actor.actorTokenId} left`);
});

// Set presence on every connection: it is not re-sent after a reconnect
client.on("connect", () => {
  room.setPresence({ username: "Alice", status: "online" });
});

await client.connect();

// Local cache, keyed by actorTokenId
const cached = room.getPresence();
console.log("In room:", Object.keys(cached).length);

// Fresh list from kraken (also refreshes the cache)
const fresh = await room.fetchPresence();
console.log("Present:", fresh.length);

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. room.on("presence:join", ...) registers a topic handler and never fires; listen on the client. kraken resolves the room by its slug in the actor's grants and silently ignores presence for a room it cannot resolve; see Presence.

Lobbies

A lobby lets one client watch presence across several rooms:

import { NoLag } from "@nolag/js-sdk";

const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });
await client.connect();

// The actor needs a grant for the lobby (see Lobbies)
const lobby = client.setApp("demo").setLobby("online");

// subscribe() resolves with a snapshot keyed by room slug, then actor id
const snapshot = await lobby.subscribe();
console.log("Rooms:", Object.keys(snapshot));

// Events carry the room they came from: { lobbyId, roomId, actorId, data }
lobby.on("presence:join", (event) => {
  console.log(`${event.actorId} joined ${event.roomId}`, event.data);
});

lobby.on("presence:leave", (event) => {
  console.log(`${event.actorId} left ${event.roomId}`);
});

// Fetch a fresh snapshot, or stop watching
const latest = await lobby.fetchPresence();
lobby.unsubscribe();

A lobby the actor is not allowed to watch is refused with not_authorized on the error event, and subscribe() then rejects with a timeout after 10 seconds. Lobbies are read-only, and the way kraken decides which rooms a lobby covers has limits worth knowing before you build on them; see Lobbies.

Filters

Filters narrow delivery to the messages published with a matching filter value:

import { NoLag } from "@nolag/js-sdk";

const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });
await client.connect();

const room = client.setApp("demo").setRoom("general");

// Only receive messages published with filter 'booking_1' or 'booking_2'
room.subscribe("messages", { filters: ["booking_1", "booking_2"] });

room.on("messages", (data, meta) => {
  console.log("Update:", data, "via", meta.filter);
});

// Publish to one filter
room.emit("messages", { status: "confirmed" }, { filter: "booking_1" });

// Change the filters of an existing subscription
room.setFilters("messages", ["booking_3", "booking_4"]);  // replace
room.addFilters("messages", ["booking_5"]);                // add
room.removeFilters("messages", ["booking_3"]);             // remove
room.setFilters("messages", []);                           // back to everything

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 |. Nested arrays express AND groups. See Filters.

setFilters only works on a topic you are subscribed to on the current connection. After a reconnect, re-subscribe with the filters you want rather than calling addFilters.

Quality of service

import { NoLag } from "@nolag/js-sdk";

// Default QoS for every subscribe and publish (default: 1)
const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws", qos: 1 });
await client.connect();

const room = client.setApp("demo").setRoom("general");

room.emit("messages", { reading: 21.5 }, { qos: 0 });
room.emit("messages", { order: 42 }, { qos: 2 });

QoS is not an end-to-end guarantee. kraken validates the level (0, 1 or 2) 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.

import { NoLag } from "@nolag/js-sdk";

// For every subscription on this client
const worker = NoLag("dev-token-alice", {
  url: "ws://localhost:8080/ws",
  loadBalance: true,
  loadBalanceGroup: "job-workers",
});

// Or per subscription
const client = NoLag("dev-token-bob", { url: "ws://localhost:8080/ws" });
await client.connect();

client.setApp("demo").setRoom("general").subscribe("messages", {
  loadBalance: true,
  loadBalanceGroup: "job-workers",
});

Without a loadBalanceGroup, 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. Do not load-balance a topic that carries replies a specific client is waiting for.

TypeScript

import { NoLag, NoLagServerError, NoLagEncodeError } from "@nolag/js-sdk";
import type {
  NoLagOptions,
  TokenProvider,
  ConnectionStatus,
  MessageMeta,
  MessageHandler,
  ActorPresence,
  PresenceData,
  LobbyPresenceEvent,
  LobbyPresenceState,
  QoS,
  SubscribeOptions,
  EmitOptions,
  RoomContext,
  LobbyContext,
} from "@nolag/js-sdk";

interface ChatMessage {
  text: string;
  sender: string;
}

const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });
const room: RoomContext = client.setApp("demo").setRoom("general");

// Payloads arrive as unknown; assert the shape you publish
const onMessage: MessageHandler = (data, meta) => {
  const msg = data as ChatMessage;
  console.log(`[${msg.sender}] ${msg.text}`, meta.msgId);
};
room.on("messages", onMessage);

From 1.14.0 the handlers are generic, so room.on<ChatMessage>("messages", (data) => data.text) types the payload directly, and the error handler is typed as NoLagServerError | NoLagEncodeError | Error.

Error handling

Broker-side failures are asynchronous: they arrive on the error event, and on the callback of the subscribe or publish that caused them. Register the error handler before connect().

import { NoLag, NoLagServerError, NoLagEncodeError } from "@nolag/js-sdk";

const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });

client.on("error", (error) => {
  if (error instanceof NoLagServerError) {
    // A structured error frame from kraken
    console.error(error.error, error.code, error.topic, error.hint);
    if (error.code === 42940) {
      // unknown_topic: no grant covers this address
    }
  } else if (error instanceof NoLagEncodeError) {
    // The payload could not be MessagePack-encoded (class instances, cycles)
    console.error("Cannot encode payload for", error.op, error.topic);
  } else {
    console.error("Transport error:", error.message);
  }
});

await client.connect();
errorcodeCause
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_authorizednoneA lobby the actor may not watch (and, at protocol version 1, refused topics)
not_subscribednonesetFilters on a topic this connection is not subscribed to
too_many_filters (max 100)noneMore than 100 filters on one subscription
invalid_filter_chars (/, #, +, | not allowed)noneA filter value containing a reserved character

Browser usage without a bundler

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

  // A static demo token is fine against a local kraken; in production
  // pass a token provider instead (see Browser authentication)
  const client = NoLag("dev-token-alice", { url: "ws://localhost:8080/ws" });
  const room = client.setApp("demo").setRoom("general");

  room.on("messages", (data) => {
    document.getElementById("output").textContent = JSON.stringify(data);
  });
  client.on("connect", () => room.subscribe("messages"));

  await client.connect();
</script>

Complete example

Two demo actors from the quickstart's auth.json, one listening and one sending:

import { NoLag, NoLagServerError } from "@nolag/js-sdk";

const url = "ws://localhost:8080/ws";

// Alice listens
const alice = NoLag("dev-token-alice", { url });
const aliceRoom = alice.setApp("demo").setRoom("general");

alice.on("error", (error) => {
  if (error instanceof NoLagServerError) {
    console.error(error.error, error.code, error.topic);
  }
});
aliceRoom.on("messages", (data) => console.log("alice got:", data));
alice.on("presence:join", (actor) => console.log("joined:", actor.actorTokenId));
alice.on("connect", () => {
  aliceRoom.subscribe("messages");
  aliceRoom.setPresence({ username: "Alice" });
});
await alice.connect();

// Bob joins and sends
const bob = NoLag("dev-token-bob", { url });
const bobRoom = bob.setApp("demo").setRoom("general");
bob.on("connect", () => {
  bobRoom.setPresence({ username: "Bob" });
});
await bob.connect();

bobRoom.emit("messages", { text: "Hello, Alice!" }, (error) => {
  if (error) console.error("send failed:", error.message);
});

Next steps