@nolag/feed
Activity feeds with posts, likes and comments, delivered live to every subscriber of a channel.
Overview
@nolag/feed powers live activity feeds for social apps, community platforms and content aggregators. Publish a post, a like or a comment once and the broker fans it out to every subscriber of the channel. Your app owns one core NoLag client and injects it into NoLagFeed; the wrapper attaches its behaviour to that connection and never opens or closes the socket.
Key features
- Live post, like and comment delivery to all channel subscribers
- Unread post counts per channel
- Unlike support with live tallies
- Flat comments per post, with running comment counts
- Optional audience filters that likes and comments inherit from their post
How it works
NoLagFeed attaches to the injected client and subscribes to the online lobby. joinChannel(name) returns a FeedChannel: each channel is a room, and the channel subscribes to three topics in it.
| Topic | Purpose |
|---|---|
posts | New posts: content, media, author, timestamp |
reactions | Like and unlike events |
comments | New comments, keyed by post ID |
A PostStore keeps posts and their like and comment counts, and comments are grouped by post ID. Everything is published with echo: false: your own post, like or comment is added to the local store and emitted as postSent, postLiked or commentSent instead of coming back from the broker.
A subscriber sees what is published from the moment it joins, and the counts on a post are the likes and comments this client has seen. kraken offers clients no message history, so load the timeline of record from your own database and use the feed for what happens next.
Topics to grant
The wrapper expects these names in its app (the nolag-feed-sdk blueprint):
| Kind | Names |
|---|---|
| Topics | posts, reactions, comments (all three subscribed in every joined channel) |
| Rooms | main-feed, plus every channel name you pass to joinChannel() |
| Lobby | online (subscribed on every connect) |
Readers need subscribe, and anyone who posts, likes or comments needs pubSub. If the online lobby is not granted, ready() resolves only after the 10 second lobby subscribe timeout.
With kraken's static auth file (format). Subscriber presence only works in rooms that have a rule carrying room_slug and room_id, and every token must use the same appId:
{
"tokens": {
"dev-token-alice": {
"actorTokenId": "alice",
"projectId": "demo-project",
"actorType": "user",
"appId": "feed-app",
"appName": "feed",
"allowedTopics": [
{ "pattern": "feed/main-feed/#", "permission": "pubSub", "room_id": "feed-main-feed", "room_slug": "main-feed" }
],
"allowedLobbies": [{ "lobby_slug": "online", "lobby_id": "feed-online" }]
}
}
}
With a @nolag/core project document (format), as one entry in apps:
{
"slug": "feed",
"name": "Feed",
"accessMode": "open",
"topics": ["posts", "reactions", "comments"],
"rooms": [{ "slug": "main-feed", "name": "Main Feed" }],
"lobbies": [{ "slug": "online", "name": "Online", "rooms": ["main-feed"] }]
}
Installation
npm install @nolag/feed @nolag/js-sdk
@nolag/feed peer-depends on @nolag/js-sdk ^1.12.0. Both packages are MIT licensed.
Quick start
import { NoLag } from '@nolag/js-sdk'
import { NoLagFeed } from '@nolag/feed'
// The app owns one core client. `token` is an actor token or a TokenProvider.
const client = NoLag(token, { url: 'ws://localhost:8080/ws' })
const feed = new NoLagFeed({
client,
appName: 'feed', // the App Slug the topics are granted under
username: 'Alice',
avatar: '/img/alice.png',
})
await client.connect() // the app owns the connection
await feed.ready() // wrapper setup complete
// Join a channel. The joined channel becomes the active channel and holds your presence.
const channel = feed.joinChannel('main-feed')
// New posts from other users
channel.on('postCreated', (post) => {
console.log(`${post.username}: ${post.content}`)
})
// Create a post. It is added to the local store and emitted as `postSent`.
const mine = channel.createPost({
content: 'Just shipped a new feature!',
media: [{ type: 'image', url: '/uploads/screenshot.png' }],
})
// Like and comment
channel.likePost(mine.id)
channel.on('postLiked', ({ postId, userId, likeCount }) => {
console.log(`${userId} liked ${postId}; now ${likeCount} likes`)
})
channel.addComment(mine.id, 'Congrats!')
channel.on('commentAdded', (comment) => {
console.log(`New comment on ${comment.postId}: ${comment.text}`)
})
// Unread posts while the channel is not active
console.log('Unread:', channel.unreadCount)
channel.markRead()
// Teardown: the wrapper releases its handlers; the app closes the socket.
feed.detach()
client.disconnect()
Reconnects
After the core client reconnects, the wrapper subscribes to the online lobby again, re-sends presence for the active channel and emits reconnected. It does not subscribe to its channel topics again: it assumes the broker restored them. kraken v0.9.0 restores subscriptions on reconnect only from what its auth backend returns, and the static auth file returns none, so on a static-auth kraken posts, likes and comments stop after a reconnect unless you subscribe again (see Production).
In 1.2.0 the wrapper's handlers are registered on the core client by topic and survive the reconnect; only the broker-side subscriptions are gone. Re-issue them from a connect handler on the core client (not reconnect, which fires before the socket is back). kraken treats a repeated subscribe as a replacement, so this is also safe where the backend did restore them.
client.on('connect', () => {
for (const channel of feed.getChannels()) {
const ctx = client.setApp('feed').setRoom(channel.name)
const { posts, reactions, comments } = channel.filters
ctx.subscribe('posts', posts.length > 0 ? { filters: posts } : {})
ctx.subscribe('reactions', reactions.length > 0 ? { filters: reactions } : {})
ctx.subscribe('comments', comments.length > 0 ? { filters: comments } : {})
}
})
Posts published while a client was disconnected are not delivered to it later; reload the timeline from your database after a reconnect if it matters.
Filters
createPost({ content, filter }) publishes a post to subscribers filtering on that value, and joinChannel(name, { filters }) or channel.setFilters() narrows a subscriber to matching posts. Reactions and comments are published with the filter of the post they belong to, and setFilters() applies to all three topics unless you pass { topic }, so a filtered subscriber does not receive likes for posts it cannot see.
Two caveats apply on kraken v0.9.0:
- Subscribers without filters receive every post, filtered or not. A filter routes traffic; it does not hide it. Keep audiences that must not see each other's posts in separate channels with their own grants.
- On the built-in syn broker, a subscriber without filters receives every message with
filterset to#, and the wrapper stores that on the posts it receives. Likes and comments it then sends on those posts go out with filter#and reach only other unfiltered subscribers. When a channel mixes filtered and unfiltered members on the syn broker, the filtered members miss the likes and comments sent by unfiltered ones.
See Filters.
Limitations on kraken v0.9.0
- Lobby. At kraken v0.9.0 a lobby's room list comes from the rooms the subscribing actor can reach and is cached per node for 5 minutes after the last lobby subscribe; after that,
userOnlineanduserOfflinefrom the lobby stop until a client subscribes again. See Lobbies. - Presence follows the active channel, which matches kraken's one presence room per connection.
API reference
NoLagFeed
Constructor options
| Option | Type | Description |
|---|---|---|
client | NoLagSocket | Required. The injected core client the app owns and connects. |
username | string | Required. Display name for this user. |
avatar | string | Optional avatar URL. |
metadata | Record<string, unknown> | Optional custom user data carried in presence. |
appName | string | The App Slug used as the topic prefix (default 'feed'). |
channels | string[] | Channels to subscribe to once the wrapper is ready. |
maxPostCache | number | Max posts kept in memory per channel (default 200). |
maxCommentCache | number | Accepted and stored (default 100) but not enforced: comments per post are kept without a cap. |
debug | boolean | Enable wrapper debug logging (default false). |
| Method / property | Returns | Description |
|---|---|---|
ready() | Promise<void> | Resolves once the first setup completed. joinChannel() throws before this. |
detach() | void | Release this wrapper's handlers and topics. Terminal; never closes the socket. |
joinChannel(name, opts?) | FeedChannel | Join a channel and make it the active channel. opts.filters limits delivery to matching posts and their likes and comments. |
leaveChannel(name) | void | Leave a channel and unsubscribe its topics. |
getChannels() | FeedChannel[] | All joined channels. |
getOnlineUsers() | FeedUser[] | Users currently online. |
updateProfile(updates) | void | Update username, avatar or metadata, sent through presence. |
localUser | FeedUser | null | The local user, available after ready(). |
Events
| Event | Payload | Description |
|---|---|---|
connected | none | First setup completed. |
disconnected | reason: string | The connection closed. |
reconnecting | none | The core client scheduled a reconnect attempt. |
reconnected | none | Setup ran again after a reconnect (see Reconnects). |
error | error: Error | An error from the core client, including broker refusals. |
userOnline | user: FeedUser | A user came online. |
userOffline | user: FeedUser | A user went offline. |
FeedChannel
| Method / property | Returns | Description |
|---|---|---|
createPost(opts) | FeedPost | Publish a post from { content, media?, data?, filter?, filters? }; returns the local copy. |
likePost(postId) / unlikePost(postId) | void | Like a post, or remove your like. |
addComment(postId, text) | FeedComment | Comment on a post; returns the local copy. |
getPosts() | FeedPost[] | Posts in the local store, oldest first. |
getComments(postId) | FeedComment[] | Comments on one post. |
getUsers() | FeedUser[] | Remote users present in this channel (active channel only). |
unreadCount / markRead() | number / void | Posts that arrived while the channel was not active, and the reset. |
filters | { posts, reactions, comments } | The current filters per topic. |
setFilters(values, opts?) / addFilters(values, opts?) / removeFilters(values, opts?) | void | Change the filters; opts.topic limits the change to one topic. |
A FeedPost has id, userId, username, avatar?, content, media?, data?, likeCount, commentCount, likedByMe, timestamp, filter? and status. A FeedComment has id, postId, userId, username, avatar?, text and timestamp. Media attachments are { type: 'image' | 'video' | 'link', url, thumbnail?, title? }.
Events
| Event | Payload | Description |
|---|---|---|
postCreated | FeedPost | A post from another user arrived. |
postSent | FeedPost | Your own post was added to the local store and published. |
postLiked | { postId, userId, likeCount } | A post received a like (yours included). |
postUnliked | { postId, userId, likeCount } | A like was removed (yours included). |
commentAdded | FeedComment | A comment from another user arrived. |
commentSent | FeedComment | Your own comment was added to the local store and published. |
subscriberJoined | FeedUser | A user joined this channel. |
subscriberLeft | FeedUser | A user left this channel. |
unreadChanged | { channel, count } | The channel's unread count changed. |