@bymax-one/nest-realtime
v1.2.0
Published
Realtime backend → frontend communication for NestJS — dual-transport SSE (default) and WebSocket (opt-in) with a unified server-side API.
Readme
✨ Overview
@bymax-one/nest-realtime is a transport-agnostic realtime channel shipped as a single npm package with 4 subpath exports — covering the SSE server module, the WebSocket module, the shared type contracts, and the React hooks that consume them.
The default transport is Server-Sent Events: browser-native, plain HTTP, no client library to ship. WebSocket via Socket.IO is one config flag away, and 'both' runs them side by side. Your application code calls the same RealtimeService either way — the transport is a deployment decision, not an architectural one.
Why nest-realtime?
- 🎯 One API, any transport —
emitToUser,emitToTenant,emitToRoom,broadcast, anddisconnectbehave identically on SSE, WebSocket, or both. Migrating between transports changes a config line, not your call sites. - 📡 SSE first, and that is the point — SSE reconnects natively, replays through
Last-Event-ID, and traverses proxies as ordinary HTTP. Most apps only push server → client, and for those WebSocket is a dependency you pay for without using. - 🔌 Auth stays yours — The library never verifies a JWT, never imports an auth package. You implement
IConnectionAuthenticator; cookie, ticket, and bearer patterns are all supported. This is enforced structurally, not by convention. - 🏢 Multi-tenant by construction — Every connection auto-joins
user:{id}and, when present,tenant:{id}. Tenant scoping is a room convention, not an afterthought bolted onto a chat library. - 🪶 Nothing in your bundle you didn't ask for —
"dependencies": {}on the server, andsocket.io-clientis reached throughawait import()so an SSE-only frontend ships 2.26 KB brotli instead of ~80 KB.
pnpm add @bymax-one/nest-realtime🔥 Features
📡 Transports
- ✅ SSE (default) — native browser reconnect,
Last-Event-IDreplay,: keepaliveheartbeat tuned for real proxies - ✅ WebSocket (opt-in) — Socket.IO gateway behind the
/websocketsubpath, with full-duplexemit; an SSE app never installs it - ✅ Composite mode —
transport: 'both', also on the/websocketsubpath, fans a single emit out to clients on either transport - ✅ Unified contract — every transport implements
ITransport, soRealtimeServicenever branches
🔐 Security & Auth
- ✅ Auth inversion — the consumer plugs
IConnectionAuthenticator; the library owns no credentials - ✅ Zero auth imports —
src/contains no reference to@nestjs/jwt,passport-*, or any auth package, verified in CI - ✅ Three handshake patterns — HttpOnly cookie (SSE-safe), one-time ticket, and bearer header (WebSocket only)
- ✅ Periodic re-authentication — revalidate credentials on an interval, with a positive-result cache
- ✅ Tenant from the token, never the body —
tenantIdcomes from the authenticated result viatenantResolver
🏢 Multi-Tenant & Delivery
- ✅ Room conventions —
user:{id},tenant:{id},resource:{type}:{id}, with the first two auto-joined - ✅ Offline queue —
IOfflineQueueStorageholds events for absent users and flushes them on reconnect - ✅ Replay buffer — per-user ring buffer answers
Last-Event-IDafter a dropped SSE stream - ✅ FIFO connection limits — the oldest connection is evicted, so a new tab never gets a 429
- ✅ Presence — optional
IPresenceStoragepowerspresence:online/presence:offline
🧩 Developer Experience
- ✅ 4 subpath exports — server, WebSocket, shared types, and React; tree-shakeable, ESM + CJS dual output
- ✅ Dynamic module —
forRoot()andforRootAsync()withuseFactory/useClass/useExisting - ✅ Strict TypeScript, zero
any—noUncheckedIndexedAccessandexactOptionalPropertyTypeson - ✅ Horizontal scaling —
IRealtimePubSubfor SSE fan-out,@socket.io/redis-adapterfor WebSocket - ✅ Bundle gate in CI — the SSE-only React bundle is measured on every build and fails over budget
📦 Subpath Exports
One package, four entry points — import only what your app needs:
| Subpath | Import | Purpose | Dependencies |
| ------------- | ------------------------------------ | ---------------------------------------------- | :---------------------------------------------------------------: |
| Server | @bymax-one/nest-realtime | NestJS module for SSE, services, contracts | NestJS 11, rxjs |
| WebSocket | @bymax-one/nest-realtime/websocket | NestJS module for 'websocket' and 'both' | + @nestjs/websockets, @nestjs/platform-socket.io, socket.io |
| Shared | @bymax-one/nest-realtime/shared | Types, room prefixes, event names, error codes | None |
| React | @bymax-one/nest-realtime/react | Hooks & RealtimeProvider | React 19 |
shared (zero deps)
↗ ↑ ↖
server websocket reactThe shared subpath carries no Node or NestJS import, so the same event names and error codes are used on both sides of the wire without duplicating a constant.
The websocket subpath exists so the server one costs nothing extra. Everything that touches @nestjs/websockets, @nestjs/platform-socket.io and socket.io is reachable only through it, so an application on SSE — the default transport — never installs the Socket.IO stack. Importing it is what opts in, in the install as much as in the configuration.
The package also exposes @bymax-one/nest-realtime/internal. It is not public API and carries no compatibility promise — it is the shared runtime the two server subpaths import so that a service registered through one and injected through the other is the same class. Import the four above; nothing you need is only there.
[!TIP] Prefer to learn from a working app? See the nest-realtime-example — a NestJS + React project wired with this library.
🚀 Quick Start
1. Install
# Using pnpm (recommended)
pnpm add @bymax-one/nest-realtime
# Using npm
npm install @bymax-one/nest-realtime
# Using yarn
yarn add @bymax-one/nest-realtime[!IMPORTANT] You must also install the peer dependencies for the subpaths you use. The library declares
"dependencies": {}— nothing is installed on your behalf.
# Server subpath (required)
pnpm add @nestjs/common @nestjs/core rxjs reflect-metadata
# WebSocket subpath (optional — only when you import @bymax-one/nest-realtime/websocket)
pnpm add @nestjs/websockets @nestjs/platform-socket.io socket.io
# Horizontal scaling (optional)
pnpm add ioredis @socket.io/redis-adapter
# React subpath (optional)
pnpm add react react-dom
# WebSocket on the frontend (optional)
pnpm add socket.io-client2. Implement the Authenticator Interface
This is the only interface you are required to provide. The library calls it once per connection and never inspects the credential itself.
import type {
IConnectionAuthenticator,
ConnectionAuthContext,
AuthenticationResult,
} from '@bymax-one/nest-realtime'
import { verifyAccessToken } from './jwt'
export class CookieAuthenticator implements IConnectionAuthenticator {
async authenticate(ctx: ConnectionAuthContext): Promise<AuthenticationResult | null> {
const token = ctx.cookies['access_token']
if (!token) return null
try {
const claims = await verifyAccessToken(token)
return { userId: claims.sub, tenantId: claims.tid }
} catch {
return null // null rejects the connection
}
}
}[!NOTE] Returning
nullrejects the connection — throwing is not required. See Auth Inversion for the ticket and bearer patterns.
3. Register the Module
// app.module.ts
import { Module } from '@nestjs/common'
import { BymaxRealtimeModule } from '@bymax-one/nest-realtime'
import { CookieAuthenticator } from './auth/cookie-authenticator'
@Module({
imports: [
BymaxRealtimeModule.forRoot({
transport: 'sse',
authenticator: new CookieAuthenticator(),
}),
],
})
export class AppModule {}Clients now connect to GET /realtime/sse (configurable via sse.endpoint).
4. Emit From Anywhere
Inject RealtimeService into any provider or controller:
import { Controller, Post, Body, Param } from '@nestjs/common'
import { RealtimeService } from '@bymax-one/nest-realtime'
@Controller('invoices')
export class InvoicesController {
constructor(private readonly realtime: RealtimeService) {}
@Post(':id/pay')
async pay(@Param('id') invoiceId: string, @Body() body: { userId: string; amount: number }) {
await this.realtime.emitToUser(body.userId, 'invoice.paid', {
invoiceId,
amount: body.amount,
})
}
}[!IMPORTANT] The emit signature is
(target, eventName, data)— three arguments. The event name is a separate string, not a field inside the payload.
5. Consume in React
Wrap the subtree once, then read from it with hooks:
// App.tsx
import { RealtimeProvider } from '@bymax-one/nest-realtime/react'
export default function App() {
return (
<RealtimeProvider options={{ url: '/realtime/sse', eventNames: ['invoice.paid'] }}>
<InvoiceList />
</RealtimeProvider>
)
}// InvoiceList.tsx
import { useRealtimeContext } from '@bymax-one/nest-realtime/react'
export function InvoiceList() {
const { events, connected } = useRealtimeContext()
return (
<>
<span>{connected ? '🟢 live' : '🔴 disconnected'}</span>
<ul>
{events.map((e, i) => (
<li key={i}>
{String(e.type)} — {JSON.stringify(e.data)}
</li>
))}
</ul>
</>
)
}[!NOTE] Events carry an
idon the SSE branch only — the WebSocket branch has no per-event id, so the shared context type does not expose one. Narrow ontransport === 'sse'if you need it.
[!IMPORTANT]
EventSourcedelivers a named event only to a listener registered for that exact name. Application-level names such asinvoice.paidmust be listed ineventNames, or they never reach the hook. Reserved and presence events are subscribed automatically.
6. Scale Across Instances
SSE connections live on the instance that accepted them. To fan an emit out to every instance, provide a pub/sub backend:
import {
BymaxRealtimeModule,
RedisRealtimePubSub,
RedisOfflineQueue,
} from '@bymax-one/nest-realtime'
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379')
BymaxRealtimeModule.forRoot({
transport: 'sse',
authenticator: new CookieAuthenticator(),
pubsub: new RedisRealtimePubSub({ client: redis }),
offlineQueue: new RedisOfflineQueue({ client: redis }),
sse: { heartbeatMs: 25_000, replayBufferSize: 100, maxConnectionsPerUser: 5 },
})For WebSocket, pass an ioredis client to websocket.redisAdapter.pubClient and @socket.io/redis-adapter is registered for you.
⚙️ Configuration
All options are passed to forRoot() / forRootAsync(). Only transport and authenticator are required.
| Group | Key Options | Default |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| transport | 'sse' | 'websocket' | 'both' | — (required) |
| authenticator | IConnectionAuthenticator | — (required) |
| tenantResolver | (auth) => string \| undefined — derives tenantId from the auth result | auth.tenantId |
| hooks | onConnect, onDisconnect, onError, onReauthenticationFailed (fire-and-forget) | — |
| pubsub | IRealtimePubSub — cross-instance fan-out for SSE | InMemoryPubSub |
| offlineQueue | IOfflineQueueStorage — events held while a user is away | — (disabled) |
| presence | IPresenceStorage — online-user tracking | — (disabled) |
| sse | endpoint, heartbeatMs, replayBufferSize, maxConnectionsPerUser, emitConnectionEvent | /realtime/sse, 30000, 100, 5, true |
| websocket | namespace, cors, maxHttpBufferSize, pingIntervalMs, pingTimeoutMs, maxConnectionsPerUser, emitConnectionEvent, redisAdapter | '/', — (Socket.IO defaults) |
| reauthenticationPolicy | intervalSeconds, onFailure ('disconnect' | 'event'), cacheTtlMs | 300, 'disconnect', 60000 |
[!NOTE]
websocket.maxConnectionsPerUseris opt-in and unlimited unless set to a positive number, whilesse.maxConnectionsPerUserdefaults to5. The two transports differ here on purpose: an SSE stream holds an HTTP connection open, a Socket.IO client multiplexes.
[!IMPORTANT]
forRootAsynctakes the SSE route on the registration, not from the factory. NestJS registers controllers at decoration time, before any factory has run, sosse.endpointcannot move the route there. Declare it assseEndpointalongsidetransport:BymaxRealtimeModule.forRootAsync({ transport: 'sse', sseEndpoint: '/realtime/sse', // defaults to '/events' inject: [ConfigService], useFactory: (cfg: ConfigService) => ({ transport: 'sse', authenticator: ... }), })The async default is
'/events', not theforRootdefault of'/realtime/sse'. A factory that returns a differentsse.endpointis rejected at bootstrap rather than ignored, andREALTIME_OPTIONS_TOKENreports the route that was actually bound — so a health check or an OpenAPI document built from the options names the path that exists.
The full reference, including forRootAsync with useFactory / useClass / useExisting, is in docs/technical_specification.md §4.
🏗️ Architecture
The package runs inside your NestJS application as a dynamic module — not as a separate realtime server:
┌────────────────────────────────────────────────────┐
│ Your NestJS Application │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ @bymax-one/nest-realtime │ │
│ │ │ │
│ │ RealtimeService ──→ ITransport │ │
│ │ │ ├── SseTransport │ │
│ │ │ ├── WebSocket… │ │
│ │ │ └── Composite… │ │
│ │ ↓ │ │
│ │ ConnectionRegistry ←→ RoomRegistry │ │
│ └────────┬──────────────┬──────────────┬───────┘ │
│ │ │ │ │
│ ┌───────▼──────┐ ┌─────▼──────┐ ┌─────▼───────┐ │
│ │ IConnection │ │ IRealtime │ │ IOffline │ │
│ │ Authenticator│ │ PubSub │ │ QueueStorage│ │
│ │ (yours) │ │ (optional) │ │ (optional) │ │
│ └──────────────┘ └────────────┘ └─────────────┘ │
└────────────────────────────────────────────────────┘
│ │
SSE (HTTP stream) WebSocket (Socket.IO)
└────────→ Browser ←─────┘Design Principles
| Principle | Description |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| 🔌 Interface-Driven | Authentication, pub/sub, offline queue, and presence are contracts you implement — Redis reference implementations ship, none are forced |
| 🔀 Transport-Agnostic | RealtimeService talks to ITransport; adding or swapping a transport never touches application code |
| 🛡️ Auth Inversion | The library holds no credential logic, so an auth vulnerability cannot originate here |
| 🪶 Zero Runtime Deps | "dependencies": {} — every dependency is a peer, and optional ones are marked in peerDependenciesMeta |
| 🌳 Tree-Shakeable | sideEffects: false, subpath exports, and a dynamic import() that keeps socket.io-client out of the SSE bundle |
Cross-Instance Emit Flow
An emit delivers locally and publishes once. Every other instance's subscriber re-emits through local-only paths (emitToUserLocal, …), so a message is never republished and cannot loop. Connection revocation crosses instances as an op: 'disconnect' message on the same channel.
Sizing guidance and adapter trade-offs are in docs/architecture/scaling-cheatsheet.md.
🔐 Security Model
Auth Inversion
This library never verifies a JWT, hashes a password, or imports an authentication package. It calls IConnectionAuthenticator.authenticate() once per connection and trusts the AuthenticationResult you return.
This is a structural guarantee, not a guideline — src/ is audited in CI for any reference to @bymax-one/nest-auth, @nestjs/jwt, or passport-*:
grep -rE "@nestjs/jwt|@bymax-one/nest-auth|passport" src/ # must return zeroThe consequence is worth stating plainly: a credential-handling vulnerability cannot originate in this package, because the code that would contain it does not exist here. It also means you are responsible for the bridge — vulnerabilities in a bridge implementation belong to that project, as SECURITY.md records.
Handshake Patterns
| Pattern | Transport | Credential source | Notes |
| ------------------- | --------- | ------------------------------ | ----------------------------------------------------------------- |
| HttpOnly cookie | SSE + WS | ctx.cookies | Recommended. The only pattern where the browser sends it for you. |
| One-time ticket | SSE + WS | ctx.query['ticket'] | Short-lived, single-use. The gateway normalizes it to one string. |
| Bearer header | WS only | ctx.headers['authorization'] | EventSource cannot send custom headers — SSE must not use this. |
export class TicketAuthenticator implements IConnectionAuthenticator {
constructor(private readonly tickets: TicketStore) {}
async authenticate(ctx: ConnectionAuthContext): Promise<AuthenticationResult | null> {
const ticket = ctx.query['ticket']
if (!ticket || Array.isArray(ticket)) return null
return this.tickets.consume(ticket) // null when expired or already used
}
}A complete @bymax-one/nest-auth bridge is in docs/examples/nest-auth-bridge.md; the per-transport handshake differences are in docs/architecture/auth-handshake-differences.md.
Re-Authentication
Long-lived connections outlive short-lived tokens. reauthenticationPolicy revalidates on an interval and either drops the connection or emits connection:reauthentication-failed, depending on onFailure. Successful checks are cached for cacheTtlMs so a busy connection is not revalidated on every event.
Security Checklist
When integrating @bymax-one/nest-realtime in production, verify each of the following:
- The SSE endpoint is served over HTTPS —
Last-Event-IDreplays past events to whoever reconnects tenantIdis derived from the authenticated result, never from a query parameter or request body- Tickets are single-use and short-lived;
consume()must be atomic under concurrency websocket.cors.originis an explicit allowlist, nottrue, in any browser-facing deploymentmaxConnectionsPerUseris set for WebSocket if clients are untrusted — it is unlimited by default- Reserved event names are not reused for application events (see the catalog)
🛡️ Security Table
| Layer | Implementation |
| --------------------- | ------------------------------------------------------------------------------ |
| Credential validation | Delegated entirely to consumer-supplied IConnectionAuthenticator |
| Auth library imports | None — enforced by a CI grep over src/ |
| Tenant isolation | Room-scoped (tenant:{id}), resolved from the authenticated result |
| Connection revocation | disconnect(connectionId), propagated cross-instance via op: 'disconnect' |
| Credential expiry | Interval re-authentication with disconnect or event on failure |
| Connection flooding | FIFO eviction per user — oldest closed, new admitted, never a 429 |
| SSE transport headers | X-Accel-Buffering: no and Cache-Control: no-cache, no-transform by default |
| WebSocket origin | Explicit cors config passed straight to Socket.IO |
[!IMPORTANT] This package holds zero credential logic. That is its central security property — and it means the strength of your realtime auth is exactly the strength of the authenticator you plug in.
🧱 Tech Stack
🧪 Testing & Quality
A realtime channel fails in ways that are hard to reproduce — dropped streams, races between reconnect and revocation, one instance not seeing another's emit. The suite is built so those failures are caught by a test, not by a user.
- ✅ 100% coverage — statements, branches, functions, and lines, enforced as a release gate
- ✅ 100.00% mutation score — 655 killed and 7 timed out, zero survivors, verified with Stryker (break threshold 100)
- ✅ 626 tests — 593 unit and integration across 37 suites, plus 33 end-to-end across 6
- ✅ Cross-instance e2e — real Redis pub/sub fan-out between two live instances, not a mocked channel
- ✅ Bundle-size gate — the SSE-only React bundle is measured on every build and fails over budget
pnpm test # unit + integration
pnpm test:e2e # end-to-end (single instance, no Redis needed)
pnpm test:cov:all # 100% coverage gate
pnpm mutation # Stryker mutation testing (~15-25 min)
pnpm build && pnpm sizeCross-instance e2e needs a Redis instance:
REDIS_URL=redis://localhost:6379 pnpm test:e2e[!NOTE] Line coverage proves a line executed under test; mutation testing proves a test would fail if that line were wrong. The full methodology and per-area breakdown are in docs/mutation_testing_results.md.
📖 API Reference
RealtimeService
The unified emit API. Identical on every transport.
| Method | Description |
| ------------------------------------- | ----------------------------------------- |
| emitToUser(userId, event, data) | Deliver to every connection of one user |
| emitToTenant(tenantId, event, data) | Deliver to every connection in a tenant |
| emitToRoom(roomId, event, data) | Deliver to an arbitrary room |
| broadcast(event, data) | Deliver to every connected client |
| joinRoom(connectionId, roomId) | Add a connection to a room |
| leaveRoom(connectionId, roomId) | Remove a connection from a room |
| disconnect(connectionId, reason?) | Revoke a connection, cross-instance aware |
All methods return Promise<void>.
ConnectionRegistry
Read-side view of live connections — useful for admin endpoints and metrics.
| Method | Returns |
| -------------------------------- | ------------------------------------------------ |
| byUser(userId, transport?) | ConnectionRecord[] for one user |
| byTenant(tenantId, transport?) | ConnectionRecord[] for one tenant |
| allByTransport(transport) | ConnectionRecord[] on 'sse' or 'websocket' |
| get(connectionId) | ConnectionRecord \| undefined |
| count() / countUsers() | Live connection count / distinct user count |
Contracts
| Interface | Required | Purpose |
| --------------------------- | :------: | ----------------------------------------------------------- |
| IConnectionAuthenticator | ✅ | Validate a connection, return userId / tenantId |
| IRealtimePubSub | — | Cross-instance fan-out for SSE |
| IOfflineQueueStorage | — | Hold events for disconnected users |
| IPresenceStorage | — | Track who is online |
| IConnectionLifecycleHooks | — | Fire-and-forget connect / disconnect / error callbacks |
| ITransport | — | Implemented by the library; the seam every transport shares |
Reference implementations shipped: InMemoryPubSub (default), RedisRealtimePubSub, RedisOfflineQueue.
Carrying handshake context into the hooks
AuthenticationResult.metadata is a free-form bag the library carries verbatim: it reaches
ConnectionEventMeta.metadata in the three hooks that receive that type — onConnect,
onDisconnect and onReauthenticationFailed — and is handed back to revalidate as part of
the original result. It is the only channel that crosses that boundary — authenticate sees
the request headers but no connectionId yet, and those hooks see the connectionId but not
the headers.
onError is the exception and takes a narrower payload (connectionId?, error, transport):
it can fire before authentication resolves, so there may be no connection to describe, which is
also why its connectionId is optional. It carries neither roles nor metadata.
class TracingAuthenticator implements IConnectionAuthenticator {
async authenticate(ctx: ConnectionAuthContext): Promise<AuthenticationResult | null> {
const claims = await verify(ctx.cookies['access_token'])
if (!claims) return null
// ctx.headers keys are lowercased; this is the only place the headers are visible.
return { userId: claims.sub, metadata: { traceparent: ctx.headers['traceparent'] } }
}
}
const hooks: IConnectionLifecycleHooks = {
onConnect: (meta) => tracer.recordConnection(meta.connectionId, meta.metadata?.['traceparent']),
}Like roles, it is a connect-time snapshot: a revalidate that keeps the connection alive
does not refresh it. The library never reads a key.
Rooms
| Room ID | Used for | Auto-joined |
| ---------------------- | ----------------------------------------------- | ----------------------------- |
| user:{userId} | Every connection belonging to one user | ✅ Always |
| tenant:{tenantId} | Every connection in a tenant | ✅ When tenantId is present |
| resource:{type}:{id} | Per-resource events, e.g. resource:invoice:42 | Manual — call joinRoom |
import { composeRoomId } from '@bymax-one/nest-realtime'
composeRoomId('USER', userId) // → "user:u_abc"
composeRoomId('TENANT', tenantId) // → "tenant:t_acme"
composeRoomId('RESOURCE', 'invoice', invoiceId) // → "resource:invoice:42"Role-scoped delivery
There is no emitToRole. Roles are the consumer's authorization vocabulary, not
the transport's — the library carries them through without interpreting them (see
Auth Inversion). To deliver to a subset of connections by role,
build a room for it in onConnect, where ConnectionEventMeta.roles carries the
snapshot the authenticator produced:
import { ModuleRef } from '@nestjs/core'
import { BymaxRealtimeModule, RealtimeService } from '@bymax-one/nest-realtime'
BymaxRealtimeModule.forRootAsync({
transport: 'sse',
inject: [ModuleRef],
useFactory: (moduleRef: ModuleRef) => ({
transport: 'sse' as const,
authenticator: new CookieAuthenticator(),
hooks: {
onConnect: async (meta) => {
if (!meta.roles?.includes('admin')) return
// Resolved lazily: the hook runs per connection, long after bootstrap, so
// `RealtimeService` — provided by this very module — cannot be injected
// into the factory that configures it.
const realtime = moduleRef.get(RealtimeService)
await realtime.joinRoom(meta.connectionId, 'role:admin')
},
},
}),
})Then deliver to exactly those connections from any provider that injects
RealtimeService:
await this.realtime.emitToRoom('role:admin', 'audit.log', entry)meta.roles is a snapshot taken at connect time; a later revalidate that keeps
the connection alive does not refresh it. When a role can be revoked mid-session,
make revalidate return false so the connection is torn down and re-established
with the new roles.
Make the hook testable: depend on the capability, not the injector
The snippet above works, and it still walks a careful consumer into a wall: a
moduleRef.get() call inside the hook means unit-testing that hook requires
faking ModuleRef, which in practice means as unknown as ModuleRef — a
suppression many codebases block outright.
Give the hook the two capabilities it actually uses instead. ModuleRef and
RealtimeService satisfy these shapes structurally, so the module passes the
real objects and a test passes plain ones, with no cast at either end:
interface RoomJoiner {
joinRoom(connectionId: string, roomId: string): Promise<void>
}
interface ServiceResolver {
get(token: typeof RealtimeService): RoomJoiner
}
function joinRoleRoom(resolver: ServiceResolver, role: string, roomId: string) {
return async (meta: ConnectionEventMeta): Promise<void> => {
if (!meta.roles?.includes(role)) return
await resolver.get(RealtimeService).joinRoom(meta.connectionId, roomId)
}
}Wire it with the real injector:
inject: [ModuleRef],
useFactory: (moduleRef: ModuleRef) => ({
transport: 'sse' as const,
authenticator: new CookieAuthenticator(),
hooks: { onConnect: joinRoleRoom(moduleRef, 'admin', 'role:admin') },
}),and test it without booting NestJS:
const joinRoom = jest.fn()
const onConnect = joinRoleRoom({ get: () => ({ joinRoom }) }, 'admin', 'role:admin')[!NOTE] Match roles by exact membership.
roles?.includes('admin')on areadonly string[]compares whole elements; reaching forroles?.some((r) => r.includes('admin'))— for case-insensitivity, say — admitsadministrator,admin-readonlyandsuperadmin. That failure is an authorization leak, it fails open, and it only shows up for role names nobody declared.
Reserved Events
Emitted by the library. Do not reuse these names for application events.
| Event | Meaning |
| -------------------------------------- | ------------------------------------------------------- |
| connection:established | Handshake accepted (suppress via emitConnectionEvent) |
| connection:reauthentication-failed | Credentials no longer valid |
| connection:credential-expiring | Credential nearing expiry |
| room:joined / room:left | Room membership changed |
| error | Transport-level error |
| presence:online / presence:offline | Presence changed (requires IPresenceStorage) |
[!NOTE] The SSE heartbeat is a
: keepalivecomment line, not a named event. It never appears in theLast-Event-IDid-space and never reaches a client listener.
Error Codes
REALTIME_INVALID_OPTIONS · REALTIME_NO_AUTHENTICATOR · REALTIME_AUTH_FAILED · REALTIME_REAUTHENTICATION_FAILED · REALTIME_TOO_MANY_CONNECTIONS · REALTIME_INVALID_TICKET · REALTIME_PUBSUB_UNAVAILABLE · REALTIME_PAYLOAD_TOO_LARGE · REALTIME_REPLAY_BUFFER_MISS
DI Tokens
REALTIME_OPTIONS_TOKEN · REALTIME_TRANSPORT_TOKEN · REALTIME_AUTHENTICATOR_TOKEN · REALTIME_PUBSUB_TOKEN · REALTIME_OFFLINE_QUEUE_TOKEN · REALTIME_PRESENCE_TOKEN · REALTIME_HOOKS_TOKEN · REALTIME_INSTANCE_ID_TOKEN
React Hooks
| Hook | Returns |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| useRealtime(options) | { transport, connected, events, lastEvent, error, reconnectAttempts, reconnect, emit } — opens the connection |
| useRealtimeContext() | The same value, from the nearest <RealtimeProvider> — throws when used outside one |
| useRealtimeConnection(options) | { connected, error, reconnectAttempts, reconnect } — status only, no events array |
| usePresence() | { onlineUserIds, isOnline(userId), count } — reads presence events from the provider |
RealtimeProvider takes a single options prop of type UseRealtimeOptions:
| Option | Type | Default | Notes |
| -------------------- | -------------------------------- | ------------- | --------------------------------------------------- |
| url | string | — (required) | ws:// or wss:// selects WebSocket automatically |
| transport | 'auto' \| 'sse' \| 'websocket' | 'auto' | Overrides URL-scheme detection |
| eventNames | readonly string[] | — | SSE only — application event names to subscribe |
| withCredentials | boolean | false | SSE only — send cookies cross-origin |
| auth | { ticket?, token? } | — | WebSocket only — handshake credentials |
| path | string | /socket.io | WebSocket only — Socket.IO path |
| reconnectInitialMs | number | 1000 | SSE only — initial backoff |
| reconnectMaxMs | number | 30000 | SSE only — backoff ceiling |
| maxAttempts | number | — (unlimited) | SSE only — give up and close after N failures |
[!NOTE]
emitis WebSocket-exclusive — SSE is server → client only.reconnectAttemptsis always0on WebSocket, where Socket.IO owns the retry policy and exposes no count.
🚧 Deployment Notes
SSE connections are long-lived HTTP responses, and several common proxy defaults break them silently:
| Concern | Fix |
| ------------------------ | ------------------------------------------------------------------------------------------------------ |
| Response compression | Disable on the SSE endpoint — Content-Encoding: gzip buffers the body and defeats streaming |
| Proxy buffering | Nginx proxy_buffering off. The library already sends X-Accel-Buffering: no |
| CDN caching | Cache-Control: no-cache, no-transform — set by the library on the SSE response |
| Idle timeout | Must exceed sse.heartbeatMs (default 30 s). AWS ALB's 60 s default is compatible |
| WebSocket + polling | Enable sticky sessions. The Redis adapter syncs messages between instances, but not handshake affinity |
Platform-specific configuration is in docs/proxies-cheat-sheet.md.
🤝 Contributing
Contributions are welcome! Please open an issue to discuss substantial changes before submitting a pull request.
# Clone the repository
git clone https://github.com/bymaxone/nest-realtime.git
cd nest-realtime
# Install dependencies
pnpm install
# Run tests
pnpm test
# Build
pnpm build
# Type check
pnpm typecheckEvery change must keep the full gate green:
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm size🔒 Security Policy
If you discover a security vulnerability, please do not open a public issue. Instead, email us at [email protected] with details. We take security seriously and will respond promptly. Full policy and scope: SECURITY.md.
