@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.
| Topic | Purpose |
|---|---|
comments | Viewer comments: text, author info, timestamp |
_reactions | Emoji reactions, aggregated client-side into bursts |
polls | Poll 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
pollsconfigured in@nolag/core, which receives every published vote (see Webhooks).
Topics to grant
| What | Value |
|---|---|
| App Slug | Whatever you pass as appName (default stream) |
| Rooms | One per stream name you pass to joinStream(). The blueprint defines live-stream |
| Topics in each room | comments, _reactions, polls |
| Lobby | online |
| Permission | pubSub 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_slugandroom_id. - Without the
allowedLobbiesentry the lobby subscribe is refused: the wrapper emits anerror(not_authorized) andready()only resolves after the core SDK's 10 second lobby timeout.
Installation
npm install @nolag/stream @nolag/js-sdkShared 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
| Option | Type | Description |
|---|---|---|
client | NoLagSocket | Required. The injected core NoLag client the app owns and connects. |
username | string | Required. Display name for this viewer. |
avatar | string | Optional avatar URL. |
role | 'viewer' | 'moderator' | 'host' | Viewer role carried in presence (default 'viewer'). It is a label; nothing restricts what a role can do. |
metadata | Record<string, unknown> | Custom data attached to viewer presence. |
appName | string | The App Slug used as the first segment of every topic (default 'stream'). |
streams | string[] | Streams to subscribe to once the wrapper is ready (without presence until you call joinStream()). |
maxCommentCache | number | Max comments kept in memory per stream (default 500). |
reactionWindow | number | Reaction burst aggregation window in ms (default 3000). |
debug | boolean | Enable wrapper debug logging (default false). |
| Method / Property | Returns | Description |
|---|---|---|
ready() | Promise<void> | Resolves once wrapper setup completed |
detach() | void | Release this wrapper's handlers and topics; terminal, never closes the socket |
joinStream(name, opts?) | StreamRoom | Join a stream and set presence in it; opts.filters limits comments and polls to matching values; throws before ready() |
leaveStream(name) | void | Leave a stream and unsubscribe from its topics |
getRooms() | StreamRoom[] | All joined streams |
getOnlineViewers() | StreamViewer[] | Viewers currently seen as online in the app |
viewerCount | number | App-wide online viewer count, including you |
localViewer | StreamViewer | null | This viewer, available once ready |
Events
| Event | Payload | Description |
|---|---|---|
connected | none | Wrapper setup completed on a fresh connection |
disconnected | reason: string | Connection closed |
reconnecting | none | The client is attempting to reconnect |
reconnected | none | Setup ran again after a reconnect: lobby and presence re-applied (topics are not; see Reconnects) |
error | error: Error | A transport or broker error, for example a refused subscribe |
viewerOnline | viewer: StreamViewer | A viewer came online |
viewerOffline | viewer: StreamViewer | A viewer left the lobby |
viewerCountChanged | count: number | The app-wide viewer count changed |
StreamRoom
Returned by joinStream(). Handles comments, reactions, polls, and per-stream viewer presence.
| Method / Property | Returns | Description |
|---|---|---|
sendComment(text, opts?) | StreamComment | Publish a comment; opts is { data?, filter?, filters? }. Returns the local copy |
getComments() / comments | StreamComment[] | Comments in the local store |
sendReaction(emoji) | void | Send a reaction to all viewers of this stream |
createPoll(opts) | Poll | Open 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) | void | Vote for an option by its zero-based index. Ignored for unknown or closed polls |
closePoll(pollId) | void | Close the poll and broadcast the final tallies as this client counted them |
activePoll | Poll | undefined | The currently open poll, if any |
getViewers() | StreamViewer[] | Remote viewers present in this stream |
viewerCount | number | Viewers present in this stream, including you |
filters | { comments, polls } | The filter values applied per topic |
setFilters(values, opts?) / addFilters(values, opts?) / removeFilters(values, opts?) | void | Change the comment and poll filters; opts.topic ('comments' or 'polls') limits the change to one topic |
Events
| Event | Payload | Description |
|---|---|---|
comment | StreamComment | A comment arrived from another viewer |
commentSent | StreamComment | Your own comment was added to the local store and published |
reaction | ReactionBurst | Reactions aggregated over reactionWindow: { emoji, count, windowStart, windowEnd }, including your own |
pollCreated | Poll | A poll this client did not know about arrived, or you created one |
pollUpdated | Poll | Vote tallies changed |
pollClosed | Poll | A poll closed |
viewerJoined | StreamViewer | A viewer set presence in this stream |
viewerLeft | StreamViewer | A viewer's presence left this stream |
viewerCountChanged | count: number | Viewer count for this stream changed |
Types
| Type | Main fields |
|---|---|
StreamComment | id, viewerId, username, avatar?, text, data?, timestamp, status ('sending' | 'sent' | 'delivered') |
Poll | id, question, options: { text, votes }[], createdBy, closed, totalVotes, timestamp |
StreamViewer | viewerId, actorTokenId, username, avatar?, role, metadata?, joinedAt, isLocal |