@nolag/stream

Live stream engagement with comments, reactions, and polls.

Overview

@nolag/stream adds realtime viewer engagement to any live stream. Viewers post comments, fire reaction bursts and vote in polls. Hosts see live viewer counts and can open and close polls on the fly. Reactions are aggregated into bursts, so a wave of emoji becomes one animation rather than a thousand. Your app owns one core NoLag client and injects it into NoLagStream; the wrapper attaches its behaviour to that connection. The video itself is yours; this SDK carries the interaction around it.

Key Features

  • Comment stream with per-viewer author info
  • Reaction bursts aggregated over a time window
  • Live polls with vote tallies, closed with closePoll()
  • Viewer counts per stream, from room presence
  • Viewer roles (viewer, moderator, host) carried in presence
  • Audience routing with filters, for example comments per language

How It Works

NoLagStream attaches to an injected @nolag/js-sdk client. Calling joinStream(name) returns a StreamRoom for the room name, subscribes to three topics in it and sets the viewer's presence there: comments for viewer comments, _reactions for emoji, and polls for poll creation, votes and close events. A comment store keeps recent comments in memory, and a poll manager tracks the active poll and its running totals.

TopicPurpose
commentsViewer comments: text, author info, timestamp
_reactionsEmoji reactions, aggregated client-side into bursts
pollsPoll creation, votes, and close events

Everything is published with echo: false: a viewer's own comments, reactions and votes are applied locally (a sent comment fires commentSent) and never come back from the broker.

Nothing is stored on the broker. A viewer who joins sees comments, reactions and poll events published from that moment on. Two consequences for polls:

  • Each client counts the votes it receives itself. A viewer who joins after a poll opened does not know the poll and ignores its votes until a later poll event about it arrives; the close event carries the closer's totals.
  • Nothing stops a viewer voting more than once. If votes must be counted exactly, count them on your backend, for example from a trigger webhook on polls configured in @nolag/core, which receives every published vote (see Webhooks).

Topics to grant

WhatValue
App SlugWhatever you pass as appName (default stream)
RoomsOne per stream name you pass to joinStream(). The blueprint defines live-stream
Topics in each roomcomments, _reactions, polls
Lobbyonline
PermissionpubSub for anyone who comments, reacts or votes; subscribe for a read-only display

Grant these either in kraken's static token file or in a @nolag/core project document. See Static auth and Project document for the full formats.

{
  "tokens": {
    "viewer-token-1": {
      "actorTokenId": "viewer-1",
      "projectId": "demo-project",
      "organizationId": "demo-org",
      "actorType": "user",
      "allowedTopics": [
        {
          "pattern": "stream/live-stream/#",
          "permission": "pubSub",
          "app_id": "stream-app",
          "room_id": "room-live-stream",
          "room_slug": "live-stream"
        }
      ],
      "allowedLobbies": [{ "lobby_slug": "online", "lobby_id": "stream-online" }]
    }
  }
}

The blueprint's online lobby lists no rooms. In a @nolag/core project document, give it the stream rooms as above: core only shows a lobby to actors that can reach at least one of its rooms, so a lobby with no rooms is visible to nobody.

Things that matter with the static file:

  • Every viewer token needs the same app_id.
  • Viewer counts come from presence, and presence only works in rooms that have a rule carrying both room_slug and room_id.
  • Without the allowedLobbies entry the lobby subscribe is refused: the wrapper emits an error (not_authorized) and ready() only resolves after the core SDK's 10 second lobby timeout.

Installation

npm install @nolag/stream @nolag/js-sdk

Shared connection. One core NoLag client can back several wrapper SDKs at once, as long as each wrapper uses a distinct appName. Each wrapper attaches its handlers on construction and releases them with detach(), and never touches the socket itself. Your app owns connect() and disconnect(). One connection holds presence in one room at a time, so a client that joins a second room (in this or any other wrapper on the same client) stops being counted in the first.

Quick Start

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

const client = NoLag('viewer-token-1', { url: 'ws://localhost:8080/ws' })

const stream = new NoLagStream({
  client,
  appName: 'stream', // the App Slug you granted
  username: 'Alice',
  role: 'viewer',    // 'viewer' | 'moderator' | 'host'
})

await client.connect()   // the app owns the connection
await stream.ready()     // wrapper setup complete

const room = stream.joinStream('live-stream')

// Viewer count for this stream, from room presence (includes you)
room.on('viewerCountChanged', (count) => {
  console.log('Viewers:', count)
})

// Your own comment is added locally and fires commentSent
room.on('commentSent', (c) => console.log('Sent:', c.text))
room.sendComment('This is amazing!')

// Comments from other viewers
room.on('comment', (c) => {
  console.log(`[${c.username}]: ${c.text}`)
})

// Reactions arrive as bursts aggregated over reactionWindow (default 3000 ms)
room.sendReaction('🔥')
room.on('reaction', ({ emoji, count }) => {
  console.log(`${count}x ${emoji}`)
})

// Open a poll (typically from the host). It stays open until closePoll().
const poll = room.createPoll({
  question: 'Which feature next?',
  options: ['Dark mode', 'Mobile app', 'API access'],
})

// Vote by zero-based option index
room.votePoll(poll.id, 1)

room.on('pollUpdated', (p) => {
  console.log('Results:', p.options.map((o) => `${o.text}: ${o.votes}`))
})

// Close it when you are done
room.closePoll(poll.id)

// Teardown: the wrapper releases its handlers; the app closes the socket.
stream.detach()
client.disconnect()

Routing by audience

Filters split a stream's comments and polls by audience, for example by language. Reactions stay stream-wide.

// A Spanish-speaking viewer receives only comments and polls tagged 'es'
const esRoom = stream.joinStream('live-stream', { filters: ['es'] })

// Tag a comment for that audience
esRoom.sendComment('¡Increíble!', { filter: 'es' })

A viewer that joins without filters receives everything, including tagged comments. Filters are routing, not access control; see Filters.

Reconnects

When the core client reconnects, the wrapper re-subscribes the online lobby, re-sets its presence in every joined stream and then emits reconnected. It does not re-subscribe the comments, _reactions and polls topics: it was written for a broker that restores subscriptions on its own. Do not rely on that with kraken (see Production); re-issue the subscriptions yourself:

stream.on('reconnected', () => {
  for (const room of stream.getRooms()) {
    const ctx = client.setApp('stream').setRoom(room.name)
    const { comments, polls } = room.filters
    ctx.subscribe('comments', comments.length > 0 ? { filters: comments } : {})
    ctx.subscribe('polls', polls.length > 0 ? { filters: polls } : {})
    ctx.subscribe('_reactions')
  }
})

The wrapper's message handlers stay registered on the client, so subscribing again is enough. Comments and poll events published while the client was away are not delivered later.

The online lobby. stream.viewerCount, viewerOnline, viewerOffline and getOnlineViewers() depend on the app's online lobby. On kraken v0.9.0 a lobby covers the rooms in the subscribing token's own grants, and that room list is cached on each kraken node for 5 minutes after the last lobby subscribe there; after that, lobby events stop until something subscribes to the lobby again. For a reliable per-stream count use room.viewerCount and the room's viewerCountChanged, which come from room presence. See Lobbies.

API Reference

NoLagStream

The main class. Attaches to the injected core client and manages the app-wide viewer list and the stream rooms.

Constructor Options

OptionTypeDescription
clientNoLagSocketRequired. The injected core NoLag client the app owns and connects.
usernamestringRequired. Display name for this viewer.
avatarstringOptional avatar URL.
role'viewer' | 'moderator' | 'host'Viewer role carried in presence (default 'viewer'). It is a label; nothing restricts what a role can do.
metadataRecord<string, unknown>Custom data attached to viewer presence.
appNamestringThe App Slug used as the first segment of every topic (default 'stream').
streamsstring[]Streams to subscribe to once the wrapper is ready (without presence until you call joinStream()).
maxCommentCachenumberMax comments kept in memory per stream (default 500).
reactionWindownumberReaction burst aggregation window in ms (default 3000).
debugbooleanEnable wrapper debug logging (default false).
Method / PropertyReturnsDescription
ready()Promise<void>Resolves once wrapper setup completed
detach()voidRelease this wrapper's handlers and topics; terminal, never closes the socket
joinStream(name, opts?)StreamRoomJoin a stream and set presence in it; opts.filters limits comments and polls to matching values; throws before ready()
leaveStream(name)voidLeave a stream and unsubscribe from its topics
getRooms()StreamRoom[]All joined streams
getOnlineViewers()StreamViewer[]Viewers currently seen as online in the app
viewerCountnumberApp-wide online viewer count, including you
localViewerStreamViewer | nullThis viewer, available once ready

Events

EventPayloadDescription
connectednoneWrapper setup completed on a fresh connection
disconnectedreason: stringConnection closed
reconnectingnoneThe client is attempting to reconnect
reconnectednoneSetup ran again after a reconnect: lobby and presence re-applied (topics are not; see Reconnects)
errorerror: ErrorA transport or broker error, for example a refused subscribe
viewerOnlineviewer: StreamViewerA viewer came online
viewerOfflineviewer: StreamViewerA viewer left the lobby
viewerCountChangedcount: numberThe app-wide viewer count changed

StreamRoom

Returned by joinStream(). Handles comments, reactions, polls, and per-stream viewer presence.

Method / PropertyReturnsDescription
sendComment(text, opts?)StreamCommentPublish a comment; opts is { data?, filter?, filters? }. Returns the local copy
getComments() / commentsStreamComment[]Comments in the local store
sendReaction(emoji)voidSend a reaction to all viewers of this stream
createPoll(opts)PollOpen a poll from { question, options: string[], filter?, filters? }; it stays open until closePoll(). Votes and the close event follow the poll's filter
votePoll(pollId, optionIndex)voidVote for an option by its zero-based index. Ignored for unknown or closed polls
closePoll(pollId)voidClose the poll and broadcast the final tallies as this client counted them
activePollPoll | undefinedThe currently open poll, if any
getViewers()StreamViewer[]Remote viewers present in this stream
viewerCountnumberViewers present in this stream, including you
filters{ comments, polls }The filter values applied per topic
setFilters(values, opts?) / addFilters(values, opts?) / removeFilters(values, opts?)voidChange the comment and poll filters; opts.topic ('comments' or 'polls') limits the change to one topic

Events

EventPayloadDescription
commentStreamCommentA comment arrived from another viewer
commentSentStreamCommentYour own comment was added to the local store and published
reactionReactionBurstReactions aggregated over reactionWindow: { emoji, count, windowStart, windowEnd }, including your own
pollCreatedPollA poll this client did not know about arrived, or you created one
pollUpdatedPollVote tallies changed
pollClosedPollA poll closed
viewerJoinedStreamViewerA viewer set presence in this stream
viewerLeftStreamViewerA viewer's presence left this stream
viewerCountChangedcount: numberViewer count for this stream changed

Types

TypeMain fields
StreamCommentid, viewerId, username, avatar?, text, data?, timestamp, status ('sending' | 'sent' | 'delivered')
Pollid, question, options: { text, votes }[], createdBy, closed, totalVotes, timestamp
StreamViewerviewerId, actorTokenId, username, avatar?, role, metadata?, joinedAt, isLocal