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);

Securing the WebSocket server

StreetWebSocketServer accepts a WsServerOptions object. Two of its options harden realtime connections against cross-site hijacking (CSWSH) and unauthenticated access.

Origin validation (allowedOrigins)

The allowedOrigins option lists the origins permitted to complete a WebSocket upgrade. Origin matching is exact on the normalized origin (scheme, host, and port).

1
2
3
const wss = new StreetWebSocketServer({
  allowedOrigins: ['https://app.example.com', 'https://admin.example.com'],
});

Same-origin default. When allowedOrigins is omitted, the server defaults to same-origin: the request’s Origin must match the server’s own scheme, host, and port. This is the secure default — a cross-site page cannot open a connection unless you explicitly allow its origin.

Origin handling rules:

Origin header Behavior
Absent Allowed. Non-browser clients (native apps, server-to-server) legitimately omit Origin, and CSWSH is a browser-only attack. To reject originless upgrades, layer your own authFn.
Present and allowed Permitted to proceed to any configured authFn.
Present but disallowed Rejected with 403 Forbidden; the socket is destroyed and no connection event is emitted.
Malformed (unparseable) Rejected — treated as disallowed.

A disallowed origin is rejected before the handshake completes, so the connection never reaches your handler.

Production warning for unauthenticated servers (F-R1)

A WebSocket server constructed in production (NODE_ENV === 'production') without an authFn accepts every upgrade unauthenticated. To make this visible, the constructor emits a one-time console.warn identifying finding F-R1 and pointing to the remediation — supply an authFn:

1
2
3
4
5
6
7
// In production without authFn, this logs a SECURITY warning referencing F-R1
const wss = new StreetWebSocketServer();

// Supplying an authFn authenticates the upgrade and suppresses the warning
const wss = new StreetWebSocketServer({
  authFn: (req) => verifySession(req),   // return false (or throw) to reject with 401
});

The warning never throws and never blocks startup — the server still starts and accepts connections according to its other configured controls. It only fires in production and only when no authFn is supplied; in development, or with an authFn present, no warning is emitted.

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.