Realtime Channels, Presence & Typing

StreetJS’s realtime layer adds named channels (rooms), reference-counted presence, typing indicators, and scoped broadcasting on top of the WebSocket server (StreetWebSocketServer / StreetSocket). The channel logic lives in ChannelHub and is transport-agnostic, so it can be unit-tested without sockets and reused across transports.

Concepts

  • Channel (room): a named group identified by a string.
  • Member: a logical user (memberId).
  • Connection: a single socket. A member may hold several connections (multi-device, or a reconnect overlapping a stale socket).
  • Presence: a member is present in a channel while at least one of their connections is in it. Presence is reference-counted by connection, so a reconnect never makes a member flicker offline.

Quick start

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { createServer } from 'node:http';
import { StreetWebSocketServer, ChannelHub, ChannelEvents } from '@streetjs/core';

const http = createServer();
const wss = new StreetWebSocketServer();
const hub = new ChannelHub({ typingTtlMs: 5_000 });

wss.attach(http, (socket, req) => {
  const memberId = deriveUser(req);     // from session/auth (see authFn)

  hub.bind(socket);                     // auto-disconnect from all channels on close
  hub.join('general', memberId, socket);
  socket.emit('presence:snapshot', { channel: 'general', members: hub.presence('general') });

  socket.on('chat', (p) =>
    hub.publish('general', 'chat', { from: memberId, text: p.text }, { exceptConnId: socket.id }));

  socket.on('typing', (p) =>
    hub.setTyping('general', memberId, p.typing === true, socket));
});

http.listen(3000);

API: ChannelHub

Method Description
join(channel, memberId, conn) Add a connection; returns { newlyPresent }. Emits presence:join to others when the member first appears.
leave(channel, memberId, conn) Remove a connection; returns { nowAbsent }. Emits presence:leave when the member’s last connection goes.
disconnect(conn) Remove a connection from all channels (call on socket close).
bind(conn) Auto-call disconnect when the connection’s onClose fires.
publish(channel, type, payload, opts?) Broadcast to the channel. opts.exceptConnId / opts.exceptMemberId exclude the sender.
presence(channel) Member ids currently present.
isPresent(channel, memberId) Presence check.
memberCount / connectionCount(channel) Counts of members / live connections.
setTyping(channel, memberId, typing, conn?) Set + broadcast typing; auto-clears after typingTtlMs when enabled.
typingMembers(channel) Member ids currently flagged typing.

Built-in events (ChannelEvents)

Constant Event type Payload
PresenceJoin presence:join { channel, memberId }
PresenceLeave presence:leave { channel, memberId }
Typing typing { channel, memberId, typing }

Reconnection

Because presence is reference-counted by connection, the recommended client flow is connect-then-replace: the reconnecting client opens a new socket and joins before the old socket is reaped by the server heartbeat. The member stays present throughout, and presence:leave only fires once the last connection is gone.

Scaling horizontally

ChannelHub keeps state in-process. To run multiple instances, place a shared pub/sub (e.g. Redis) in front of publish and the presence events so a message published on one node reaches members connected to another. The hub’s surface (publish, presence, ChannelEvents) is the integration seam for that fan-out.

Example

A complete, runnable end-to-end example (real server + two real clients) lives at examples/04-realtime-chat:

1
2
npm run build:app -w packages/core
node examples/04-realtime-chat/main.mjs

Tests

packages/core/src/tests/channels.test.ts covers membership, presence, multi-device reference counting, reconnection stability, scoped broadcasting, typing (including TTL auto-clear), validation, and a property test asserting presence always equals the set of members with at least one live connection. It runs as part of the Core coverage suite.