@venizia/ignis-docs 0.0.5 → 0.0.6-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/wiki/best-practices/architectural-patterns.md +0 -2
- package/wiki/best-practices/architecture-decisions.md +0 -8
- package/wiki/best-practices/code-style-standards/control-flow.md +1 -1
- package/wiki/best-practices/code-style-standards/index.md +0 -1
- package/wiki/best-practices/code-style-standards/tooling.md +0 -3
- package/wiki/best-practices/contribution-workflow.md +12 -12
- package/wiki/best-practices/index.md +4 -14
- package/wiki/best-practices/performance-optimization.md +3 -3
- package/wiki/best-practices/security-guidelines.md +2 -2
- package/wiki/best-practices/troubleshooting-tips.md +1 -1
- package/wiki/guides/core-concepts/application/bootstrapping.md +6 -7
- package/wiki/guides/core-concepts/components-guide.md +1 -1
- package/wiki/guides/core-concepts/components.md +2 -2
- package/wiki/guides/core-concepts/dependency-injection.md +4 -5
- package/wiki/guides/core-concepts/persistent/datasources.md +4 -5
- package/wiki/guides/core-concepts/services.md +1 -1
- package/wiki/guides/get-started/5-minute-quickstart.md +4 -5
- package/wiki/guides/get-started/philosophy.md +12 -24
- package/wiki/guides/index.md +2 -9
- package/wiki/guides/reference/mcp-docs-server.md +13 -13
- package/wiki/guides/tutorials/building-a-crud-api.md +10 -10
- package/wiki/guides/tutorials/complete-installation.md +11 -12
- package/wiki/guides/tutorials/ecommerce-api.md +3 -3
- package/wiki/guides/tutorials/realtime-chat.md +6 -6
- package/wiki/guides/tutorials/testing.md +4 -5
- package/wiki/index.md +8 -14
- package/wiki/references/base/bootstrapping.md +0 -3
- package/wiki/references/base/components.md +2 -2
- package/wiki/references/base/controllers.md +0 -1
- package/wiki/references/base/datasources.md +1 -1
- package/wiki/references/base/dependency-injection.md +2 -2
- package/wiki/references/base/filter-system/default-filter.md +2 -3
- package/wiki/references/base/filter-system/index.md +1 -1
- package/wiki/references/base/filter-system/quick-reference.md +0 -14
- package/wiki/references/base/middlewares.md +0 -8
- package/wiki/references/base/providers.md +0 -9
- package/wiki/references/base/repositories/advanced.md +1 -1
- package/wiki/references/base/repositories/mixins.md +2 -3
- package/wiki/references/base/services.md +0 -1
- package/wiki/references/components/authentication/api.md +444 -0
- package/wiki/references/components/authentication/errors.md +177 -0
- package/wiki/references/components/authentication/index.md +571 -0
- package/wiki/references/components/authentication/usage.md +781 -0
- package/wiki/references/components/health-check.md +292 -103
- package/wiki/references/components/index.md +14 -12
- package/wiki/references/components/mail/api.md +505 -0
- package/wiki/references/components/mail/errors.md +176 -0
- package/wiki/references/components/mail/index.md +535 -0
- package/wiki/references/components/mail/usage.md +404 -0
- package/wiki/references/components/request-tracker.md +229 -25
- package/wiki/references/components/socket-io/api.md +1051 -0
- package/wiki/references/components/socket-io/errors.md +119 -0
- package/wiki/references/components/socket-io/index.md +410 -0
- package/wiki/references/components/socket-io/usage.md +322 -0
- package/wiki/references/components/static-asset/api.md +261 -0
- package/wiki/references/components/static-asset/errors.md +89 -0
- package/wiki/references/components/static-asset/index.md +617 -0
- package/wiki/references/components/static-asset/usage.md +364 -0
- package/wiki/references/components/swagger.md +390 -110
- package/wiki/references/components/template/api-page.md +125 -0
- package/wiki/references/components/template/errors-page.md +100 -0
- package/wiki/references/components/template/index.md +104 -0
- package/wiki/references/components/template/setup-page.md +134 -0
- package/wiki/references/components/template/single-page.md +132 -0
- package/wiki/references/components/template/usage-page.md +127 -0
- package/wiki/references/components/websocket/api.md +508 -0
- package/wiki/references/components/websocket/errors.md +123 -0
- package/wiki/references/components/websocket/index.md +453 -0
- package/wiki/references/components/websocket/usage.md +475 -0
- package/wiki/references/helpers/cron/index.md +224 -0
- package/wiki/references/helpers/crypto/index.md +537 -0
- package/wiki/references/helpers/env/index.md +214 -0
- package/wiki/references/helpers/error/index.md +232 -0
- package/wiki/references/helpers/index.md +16 -15
- package/wiki/references/helpers/inversion/index.md +608 -0
- package/wiki/references/helpers/logger/index.md +600 -0
- package/wiki/references/helpers/network/api.md +986 -0
- package/wiki/references/helpers/network/index.md +620 -0
- package/wiki/references/helpers/queue/index.md +589 -0
- package/wiki/references/helpers/redis/index.md +495 -0
- package/wiki/references/helpers/socket-io/api.md +497 -0
- package/wiki/references/helpers/socket-io/index.md +513 -0
- package/wiki/references/helpers/storage/api.md +705 -0
- package/wiki/references/helpers/storage/index.md +583 -0
- package/wiki/references/helpers/template/index.md +66 -0
- package/wiki/references/helpers/template/single-page.md +126 -0
- package/wiki/references/helpers/testing/index.md +510 -0
- package/wiki/references/helpers/types/index.md +512 -0
- package/wiki/references/helpers/uid/index.md +272 -0
- package/wiki/references/helpers/websocket/api.md +736 -0
- package/wiki/references/helpers/websocket/index.md +574 -0
- package/wiki/references/helpers/worker-thread/index.md +470 -0
- package/wiki/references/index.md +2 -9
- package/wiki/references/quick-reference.md +3 -18
- package/wiki/references/utilities/jsx.md +1 -8
- package/wiki/references/utilities/statuses.md +0 -7
- package/wiki/references/components/authentication.md +0 -476
- package/wiki/references/components/mail.md +0 -687
- package/wiki/references/components/socket-io.md +0 -562
- package/wiki/references/components/static-asset.md +0 -1277
- package/wiki/references/helpers/cron.md +0 -108
- package/wiki/references/helpers/crypto.md +0 -132
- package/wiki/references/helpers/env.md +0 -83
- package/wiki/references/helpers/error.md +0 -97
- package/wiki/references/helpers/inversion.md +0 -176
- package/wiki/references/helpers/logger.md +0 -296
- package/wiki/references/helpers/network.md +0 -396
- package/wiki/references/helpers/queue.md +0 -150
- package/wiki/references/helpers/redis.md +0 -142
- package/wiki/references/helpers/socket-io.md +0 -932
- package/wiki/references/helpers/storage.md +0 -665
- package/wiki/references/helpers/testing.md +0 -133
- package/wiki/references/helpers/types.md +0 -167
- package/wiki/references/helpers/uid.md +0 -167
- package/wiki/references/helpers/worker-thread.md +0 -178
- package/wiki/references/src-details/boot.md +0 -379
- package/wiki/references/src-details/core.md +0 -263
- package/wiki/references/src-details/dev-configs.md +0 -298
- package/wiki/references/src-details/docs.md +0 -71
- package/wiki/references/src-details/helpers.md +0 -211
- package/wiki/references/src-details/index.md +0 -86
- package/wiki/references/src-details/inversion.md +0 -340
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
# WebSocket
|
|
2
|
+
|
|
3
|
+
Bun-native WebSocket server and Redis-backed emitter for real-time communication with post-connection authentication, room management, cross-instance messaging, and optional per-client encryption.
|
|
4
|
+
|
|
5
|
+
> [!IMPORTANT]
|
|
6
|
+
> **Bun only.** The `WebSocketServerHelper` uses Bun's native WebSocket API and will not work on Node.js. For Node.js support, use the [Socket.IO Helper](../socket-io/) instead.
|
|
7
|
+
|
|
8
|
+
## Quick Reference
|
|
9
|
+
|
|
10
|
+
| Class | Extends | Role |
|
|
11
|
+
|-------|---------|------|
|
|
12
|
+
| `WebSocketServerHelper` | `BaseHelper` | Bun-native WebSocket server with auth, rooms, heartbeat, Redis Pub/Sub scaling |
|
|
13
|
+
| `WebSocketEmitter` | `BaseHelper` | Publish messages to WebSocket clients from any process via Redis |
|
|
14
|
+
|
|
15
|
+
#### Import Paths
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// Server helper
|
|
19
|
+
import { WebSocketServerHelper } from '@venizia/ignis-helpers';
|
|
20
|
+
|
|
21
|
+
// Emitter helper
|
|
22
|
+
import { WebSocketEmitter } from '@venizia/ignis-helpers';
|
|
23
|
+
|
|
24
|
+
// Types and constants
|
|
25
|
+
import type {
|
|
26
|
+
IWebSocketServerOptions,
|
|
27
|
+
IWebSocketEmitterOptions,
|
|
28
|
+
IWebSocketClient,
|
|
29
|
+
IWebSocketMessage,
|
|
30
|
+
TWebSocketAuthenticateFn,
|
|
31
|
+
TWebSocketValidateRoomFn,
|
|
32
|
+
TWebSocketClientConnectedFn,
|
|
33
|
+
TWebSocketClientDisconnectedFn,
|
|
34
|
+
TWebSocketMessageHandler,
|
|
35
|
+
TWebSocketOutboundTransformer,
|
|
36
|
+
TWebSocketHandshakeFn,
|
|
37
|
+
} from '@venizia/ignis-helpers';
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
WebSocketEvents,
|
|
41
|
+
WebSocketChannels,
|
|
42
|
+
WebSocketDefaults,
|
|
43
|
+
WebSocketMessageTypes,
|
|
44
|
+
WebSocketClientStates,
|
|
45
|
+
} from '@venizia/ignis-helpers';
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Creating an Instance
|
|
49
|
+
|
|
50
|
+
### Server
|
|
51
|
+
|
|
52
|
+
`WebSocketServerHelper` wraps Bun's native WebSocket server with built-in authentication, client tracking, room management, Redis Pub/Sub for horizontal scaling, and application-level heartbeat.
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import { WebSocketServerHelper } from '@venizia/ignis-helpers';
|
|
56
|
+
|
|
57
|
+
const helper = new WebSocketServerHelper({
|
|
58
|
+
identifier: 'my-ws-server',
|
|
59
|
+
path: '/ws',
|
|
60
|
+
server: bunServerInstance, // Bun.Server
|
|
61
|
+
redisConnection: myRedisHelper, // DefaultRedisHelper
|
|
62
|
+
authenticateFn: async (data) => {
|
|
63
|
+
const { token } = data as { token: string };
|
|
64
|
+
const user = await verifyJWT(token);
|
|
65
|
+
if (!user) return null; // Reject
|
|
66
|
+
return { userId: user.id }; // Accept
|
|
67
|
+
},
|
|
68
|
+
validateRoomFn: ({ clientId, userId, rooms }) => {
|
|
69
|
+
return rooms.filter(room => room.startsWith('public-'));
|
|
70
|
+
},
|
|
71
|
+
clientConnectedFn: ({ clientId, userId }) => {
|
|
72
|
+
console.log('Client authenticated:', clientId, userId);
|
|
73
|
+
},
|
|
74
|
+
clientDisconnectedFn: ({ clientId, userId }) => {
|
|
75
|
+
console.log('Client disconnected:', clientId, userId);
|
|
76
|
+
},
|
|
77
|
+
messageHandler: ({ clientId, userId, message }) => {
|
|
78
|
+
console.log('Custom event:', message.event, message.data);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await helper.configure();
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
#### `IWebSocketServerOptions`
|
|
86
|
+
|
|
87
|
+
| Option | Type | Required | Default | Description |
|
|
88
|
+
|--------|------|----------|---------|-------------|
|
|
89
|
+
| `identifier` | `string` | Yes | -- | Unique name for this WebSocket server instance |
|
|
90
|
+
| `path` | `string` | No | `'/ws'` | URL path for WebSocket upgrade requests |
|
|
91
|
+
| `server` | `IBunServer` | Yes | -- | Bun server instance (provides `publish()` for native pub/sub) |
|
|
92
|
+
| `redisConnection` | `DefaultRedisHelper` | Yes | -- | Redis helper for cross-instance messaging. Creates 2 duplicate connections internally |
|
|
93
|
+
| `defaultRooms` | `string[]` | No | `['ws-default', 'ws-notification']` | Rooms clients auto-join after authentication |
|
|
94
|
+
| `serverOptions` | `IBunWebSocketConfig` | No | See defaults below | Bun native WebSocket configuration |
|
|
95
|
+
| `authTimeout` | `number` | No | `5000` (5s) | Milliseconds before unauthenticated clients are disconnected (close code `4001`) |
|
|
96
|
+
| `heartbeatInterval` | `number` | No | `30000` (30s) | Milliseconds between heartbeat sweeps |
|
|
97
|
+
| `heartbeatTimeout` | `number` | No | `90000` (90s) | Milliseconds of inactivity before a client is closed (close code `4002`) |
|
|
98
|
+
| `encryptedBatchLimit` | `number` | No | `10` | Max concurrent encryption operations for room/broadcast delivery |
|
|
99
|
+
| `requireEncryption` | `boolean` | No | `false` | When `true`, clients must complete ECDH handshake during auth or get disconnected (code `4004`) |
|
|
100
|
+
| `authenticateFn` | `TWebSocketAuthenticateFn` | Yes | -- | Called when client sends `{ event: 'authenticate' }`. Return `{ userId, metadata }` on success, `null`/`false` to reject |
|
|
101
|
+
| `validateRoomFn` | `TWebSocketValidateRoomFn` | No | -- | Called when client requests to join rooms. Return allowed room names. All joins are rejected if not provided |
|
|
102
|
+
| `clientConnectedFn` | `TWebSocketClientConnectedFn` | No | -- | Called after successful authentication |
|
|
103
|
+
| `clientDisconnectedFn` | `TWebSocketClientDisconnectedFn` | No | -- | Called when a client disconnects |
|
|
104
|
+
| `messageHandler` | `TWebSocketMessageHandler` | No | -- | Called for non-system events from authenticated clients |
|
|
105
|
+
| `outboundTransformer` | `TWebSocketOutboundTransformer` | No | -- | Intercepts outbound messages before `socket.send()`. Enables per-client encryption |
|
|
106
|
+
| `handshakeFn` | `TWebSocketHandshakeFn` | No | -- | ECDH key exchange callback. Required when `requireEncryption` is `true`. Returns `{ serverPublicKey, salt }` on success |
|
|
107
|
+
|
|
108
|
+
#### Generic Type Parameters
|
|
109
|
+
|
|
110
|
+
`WebSocketServerHelper` supports two generics for type-safe auth payloads and client metadata:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
interface AuthPayload { type: string; token: string; publicKey?: string }
|
|
114
|
+
interface UserMetadata { role: string; permissions: string[] }
|
|
115
|
+
|
|
116
|
+
const helper = new WebSocketServerHelper<AuthPayload, UserMetadata>({
|
|
117
|
+
identifier: 'typed-ws',
|
|
118
|
+
server: bunServer,
|
|
119
|
+
redisConnection: redis,
|
|
120
|
+
authenticateFn: async (data) => {
|
|
121
|
+
// data is typed as AuthPayload
|
|
122
|
+
const user = await verifyJWT(data.token);
|
|
123
|
+
if (!user) return null;
|
|
124
|
+
return {
|
|
125
|
+
userId: user.id,
|
|
126
|
+
metadata: { role: user.role, permissions: user.permissions },
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
clientConnectedFn: ({ metadata }) => {
|
|
130
|
+
// metadata is typed as UserMetadata | undefined
|
|
131
|
+
if (metadata?.role === 'admin') {
|
|
132
|
+
console.log('Admin connected with permissions:', metadata.permissions);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Emitter
|
|
139
|
+
|
|
140
|
+
`WebSocketEmitter` is a lightweight Redis-only publisher for sending messages to WebSocket clients from non-WebSocket processes (background workers, microservices, cron jobs). It uses `serverId: 'emitter'` so all server instances process its messages (no dedup).
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import { WebSocketEmitter } from '@venizia/ignis-helpers';
|
|
144
|
+
|
|
145
|
+
const emitter = new WebSocketEmitter({
|
|
146
|
+
identifier: 'my-emitter',
|
|
147
|
+
redisConnection: myRedisHelper,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await emitter.configure();
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
#### `IWebSocketEmitterOptions`
|
|
154
|
+
|
|
155
|
+
| Option | Type | Required | Default | Description |
|
|
156
|
+
|--------|------|----------|---------|-------------|
|
|
157
|
+
| `identifier` | `string` | No | `'WebSocketEmitter'` | Unique name for logging |
|
|
158
|
+
| `redisConnection` | `DefaultRedisHelper` | Yes | -- | Redis helper. Creates 1 duplicate connection internally |
|
|
159
|
+
|
|
160
|
+
## Usage
|
|
161
|
+
|
|
162
|
+
### Server Setup
|
|
163
|
+
|
|
164
|
+
After constructing the helper, call `configure()` to initialize Redis connections, set up pub/sub subscriptions, and start the heartbeat timer. Then wire the Bun WebSocket handler into your server:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
const helper = new WebSocketServerHelper({
|
|
168
|
+
identifier: 'my-ws',
|
|
169
|
+
server: bunServer,
|
|
170
|
+
redisConnection: redis,
|
|
171
|
+
authenticateFn: async (data) => {
|
|
172
|
+
const user = await verifyJWT((data as { token: string }).token);
|
|
173
|
+
return user ? { userId: user.id } : null;
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await helper.configure();
|
|
178
|
+
|
|
179
|
+
// Get the Bun WebSocket handler
|
|
180
|
+
const wsHandler = helper.getBunWebSocketHandler();
|
|
181
|
+
|
|
182
|
+
// Wire into the Bun server
|
|
183
|
+
bunServer.reload({
|
|
184
|
+
fetch: myFetchHandler,
|
|
185
|
+
websocket: wsHandler,
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Handling Connections
|
|
190
|
+
|
|
191
|
+
The server implements a post-connection authentication flow. Clients connect first (the WebSocket upgrade is always accepted), then must send an `authenticate` event with credentials before they can interact.
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
Client Server (WebSocketServerHelper)
|
|
195
|
+
| |
|
|
196
|
+
|-- WebSocket upgrade ---------> | server.upgrade(req, { data: { clientId } })
|
|
197
|
+
| |
|
|
198
|
+
|-- open event ----------------> | onClientConnect()
|
|
199
|
+
| | |-- Create client entry (state: UNAUTHORIZED)
|
|
200
|
+
| | |-- Subscribe to clientId topic (Bun pub/sub)
|
|
201
|
+
| | +-- Start authTimeout (5s default)
|
|
202
|
+
| |
|
|
203
|
+
| { event: 'authenticate', |
|
|
204
|
+
| data: { token: '...' } } --> | handleAuthenticate()
|
|
205
|
+
| | |-- Set state: AUTHENTICATING
|
|
206
|
+
| | +-- Call authenticateFn(data)
|
|
207
|
+
| |
|
|
208
|
+
| | -- Success: { userId, metadata } --
|
|
209
|
+
| | |-- Set state: AUTHENTICATED
|
|
210
|
+
| | |-- Index by userId
|
|
211
|
+
| | |-- Join default rooms
|
|
212
|
+
| <-- { event: 'connected', | +-- Send 'connected' event
|
|
213
|
+
| data: { id, userId, |
|
|
214
|
+
| time } } ------------- |
|
|
215
|
+
| |
|
|
216
|
+
| | -- Failure: null/false --
|
|
217
|
+
| <-- { event: 'error' } ------- | +-- Close with code 4003
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
#### Client States
|
|
221
|
+
|
|
222
|
+
| State | Value | Description |
|
|
223
|
+
|-------|-------|-------------|
|
|
224
|
+
| `WebSocketClientStates.UNAUTHORIZED` | `'unauthorized'` | Initial state after connection |
|
|
225
|
+
| `WebSocketClientStates.AUTHENTICATING` | `'authenticating'` | `authenticate` event received, awaiting `authenticateFn` |
|
|
226
|
+
| `WebSocketClientStates.AUTHENTICATED` | `'authenticated'` | Successfully authenticated, fully operational |
|
|
227
|
+
| `WebSocketClientStates.DISCONNECTED` | `'disconnected'` | Client has disconnected |
|
|
228
|
+
|
|
229
|
+
#### Close Codes
|
|
230
|
+
|
|
231
|
+
| Code | Meaning | Trigger |
|
|
232
|
+
|------|---------|---------|
|
|
233
|
+
| `4001` | Authentication timeout | Client did not authenticate within `authTimeout` (5s default) |
|
|
234
|
+
| `4002` | Heartbeat timeout | No activity for `heartbeatTimeout` (90s default) |
|
|
235
|
+
| `4003` | Authentication failed | `authenticateFn` returned `null`/`false` or threw |
|
|
236
|
+
| `4004` | Encryption required | `requireEncryption` is `true` and `handshakeFn` rejected or was not configured |
|
|
237
|
+
| `1001` | Going away | Server shutting down gracefully |
|
|
238
|
+
|
|
239
|
+
### Sending Messages
|
|
240
|
+
|
|
241
|
+
#### Local Delivery
|
|
242
|
+
|
|
243
|
+
Send messages directly to clients, users, or rooms on the current server instance:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
// Send to a specific client
|
|
247
|
+
helper.sendToClient({
|
|
248
|
+
clientId: 'abc-123',
|
|
249
|
+
event: 'notification',
|
|
250
|
+
data: { message: 'Hello!' },
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Send to all clients belonging to a user
|
|
254
|
+
helper.sendToUser({
|
|
255
|
+
userId: 'user-123',
|
|
256
|
+
event: 'notification',
|
|
257
|
+
data: { message: 'You have a new message' },
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Send to all clients in a room
|
|
261
|
+
helper.sendToRoom({
|
|
262
|
+
room: 'ws-notification',
|
|
263
|
+
event: 'alert',
|
|
264
|
+
data: { level: 'warning', text: 'CPU high' },
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Send to all clients in a room, excluding specific clients
|
|
268
|
+
helper.sendToRoom({
|
|
269
|
+
room: 'game-lobby',
|
|
270
|
+
event: 'player-moved',
|
|
271
|
+
data: { x: 10, y: 20 },
|
|
272
|
+
exclude: ['abc-123'],
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
#### Cross-Instance Delivery
|
|
277
|
+
|
|
278
|
+
Use `send()` to deliver messages both locally and via Redis for horizontal scaling:
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
// Send to specific client (local + Redis)
|
|
282
|
+
helper.send({
|
|
283
|
+
destination: clientId,
|
|
284
|
+
payload: { topic: 'notification', data: { message: 'Hello!' } },
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Send to room (local + Redis)
|
|
288
|
+
helper.send({
|
|
289
|
+
destination: 'ws-notification',
|
|
290
|
+
payload: { topic: 'alert', data: { level: 'warning', text: 'CPU high' } },
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Broadcast to all (local + Redis)
|
|
294
|
+
helper.send({
|
|
295
|
+
payload: { topic: 'system:announcement', data: { text: 'Maintenance in 5 min' } },
|
|
296
|
+
});
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Broadcasting
|
|
300
|
+
|
|
301
|
+
Broadcast to all authenticated clients on the current instance:
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
helper.broadcast({
|
|
305
|
+
event: 'system:announcement',
|
|
306
|
+
data: { text: 'Maintenance in 5 min' },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// With exclusions
|
|
310
|
+
helper.broadcast({
|
|
311
|
+
event: 'system:announcement',
|
|
312
|
+
data: { text: 'Maintenance in 5 min' },
|
|
313
|
+
exclude: ['abc-123'],
|
|
314
|
+
});
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### Emitter Pattern
|
|
318
|
+
|
|
319
|
+
Use `WebSocketEmitter` from processes that do not run a WebSocket server (background workers, microservices, cron jobs):
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
const emitter = new WebSocketEmitter({
|
|
323
|
+
identifier: 'cron-emitter',
|
|
324
|
+
redisConnection: redis,
|
|
325
|
+
});
|
|
326
|
+
await emitter.configure();
|
|
327
|
+
|
|
328
|
+
// Send to a specific client
|
|
329
|
+
await emitter.toClient({
|
|
330
|
+
clientId: 'abc-123',
|
|
331
|
+
event: 'notification',
|
|
332
|
+
data: { message: 'Your report is ready' },
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Send to all sessions of a user
|
|
336
|
+
await emitter.toUser({
|
|
337
|
+
userId: 'user-123',
|
|
338
|
+
event: 'notification',
|
|
339
|
+
data: { message: 'New message from admin' },
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Send to a room
|
|
343
|
+
await emitter.toRoom({
|
|
344
|
+
room: 'ws-notification',
|
|
345
|
+
event: 'alert',
|
|
346
|
+
data: { level: 'critical', text: 'Database failover' },
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Broadcast to all clients
|
|
350
|
+
await emitter.broadcast({
|
|
351
|
+
event: 'system:announcement',
|
|
352
|
+
data: { text: 'Scheduled maintenance in 5 minutes' },
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Graceful shutdown
|
|
356
|
+
await emitter.shutdown();
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Rooms
|
|
360
|
+
|
|
361
|
+
After authentication, clients auto-join default rooms (configurable via `defaultRooms`, defaults to `['ws-default', 'ws-notification']`) and their own `clientId` room.
|
|
362
|
+
|
|
363
|
+
#### Client-Initiated Room Management
|
|
364
|
+
|
|
365
|
+
Clients can request to join or leave rooms by sending events:
|
|
366
|
+
|
|
367
|
+
```javascript
|
|
368
|
+
// Client-side (browser)
|
|
369
|
+
ws.send(JSON.stringify({ event: 'join', data: { rooms: ['game-lobby', 'chat-room'] } }));
|
|
370
|
+
ws.send(JSON.stringify({ event: 'leave', data: { rooms: ['game-lobby'] } }));
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
> [!WARNING]
|
|
374
|
+
> Without a `validateRoomFn` bound, clients **cannot** join any custom rooms. All join requests are silently rejected. This is a security-by-default design.
|
|
375
|
+
|
|
376
|
+
Room names are validated before joining:
|
|
377
|
+
- Must be a non-empty string
|
|
378
|
+
- Maximum 256 characters
|
|
379
|
+
- Cannot start with the internal prefix `ws:` (reserved for Redis channels)
|
|
380
|
+
|
|
381
|
+
#### Programmatic Room Management
|
|
382
|
+
|
|
383
|
+
From your service code, manage rooms directly via the helper:
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
// Join a room
|
|
387
|
+
helper.joinRoom({ clientId: 'abc-123', room: 'game-lobby' });
|
|
388
|
+
|
|
389
|
+
// Leave a room
|
|
390
|
+
helper.leaveRoom({ clientId: 'abc-123', room: 'game-lobby' });
|
|
391
|
+
|
|
392
|
+
// Get clients in a room
|
|
393
|
+
const clients = helper.getClientsByRoom({ room: 'game-lobby' });
|
|
394
|
+
console.log('Room has', clients.length, 'clients');
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### Heartbeat
|
|
398
|
+
|
|
399
|
+
The WebSocket helper uses a **passive heartbeat** model. The server does not send pings to clients. Instead, clients must periodically send `{ event: 'heartbeat' }` messages to keep their connection alive.
|
|
400
|
+
|
|
401
|
+
1. The server runs a periodic sweep at `heartbeatInterval` (default: 30s).
|
|
402
|
+
2. On each sweep, it checks every authenticated client's `lastActivity` timestamp.
|
|
403
|
+
3. If `now - lastActivity > heartbeatTimeout` (default: 90s), the client is closed with code `4002`.
|
|
404
|
+
4. Any message from the client (including `{ event: 'heartbeat' }`) updates `lastActivity`.
|
|
405
|
+
|
|
406
|
+
```javascript
|
|
407
|
+
// Client-side (browser)
|
|
408
|
+
const ws = new WebSocket('wss://example.com/ws');
|
|
409
|
+
|
|
410
|
+
// After authentication, start heartbeat
|
|
411
|
+
const heartbeatInterval = setInterval(() => {
|
|
412
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
413
|
+
ws.send(JSON.stringify({ event: 'heartbeat' }));
|
|
414
|
+
}
|
|
415
|
+
}, 30000); // Every 30 seconds
|
|
416
|
+
|
|
417
|
+
ws.onclose = () => clearInterval(heartbeatInterval);
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
> [!NOTE]
|
|
421
|
+
> `sendPings` and `idleTimeout` in the Bun server options are **transport-level** mechanisms. They are separate from the application-level heartbeat system which tracks `lastActivity` via actual message content.
|
|
422
|
+
|
|
423
|
+
### Encryption
|
|
424
|
+
|
|
425
|
+
The WebSocket helper supports **per-client encryption** via an outbound transformer -- a callback that intercepts every outbound message before `socket.send()`.
|
|
426
|
+
|
|
427
|
+
#### Enforced Encryption
|
|
428
|
+
|
|
429
|
+
When `requireEncryption` is `true`, clients must complete an ECDH key exchange during authentication:
|
|
430
|
+
|
|
431
|
+
```typescript
|
|
432
|
+
const helper = new WebSocketServerHelper({
|
|
433
|
+
identifier: 'encrypted-ws',
|
|
434
|
+
server: bunServer,
|
|
435
|
+
redisConnection: redis,
|
|
436
|
+
requireEncryption: true,
|
|
437
|
+
authenticateFn: async (data) => {
|
|
438
|
+
const user = await verifyJWT(data.token as string);
|
|
439
|
+
return user ? { userId: user.id } : null;
|
|
440
|
+
},
|
|
441
|
+
handshakeFn: async ({ clientId, userId, data }) => {
|
|
442
|
+
const clientPubKeyB64 = data.publicKey as string;
|
|
443
|
+
if (!clientPubKeyB64) return null; // Reject
|
|
444
|
+
|
|
445
|
+
const peerKey = await ecdh.importPublicKey({ rawKeyB64: clientPubKeyB64 });
|
|
446
|
+
const salt = crypto.getRandomValues(new Uint8Array(32));
|
|
447
|
+
const saltB64 = Buffer.from(salt).toString('base64');
|
|
448
|
+
const aesKey = await ecdh.deriveAESKey({
|
|
449
|
+
privateKey: serverKeyPair.keyPair.privateKey,
|
|
450
|
+
peerPublicKey: peerKey,
|
|
451
|
+
salt,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
clientKeys.set(clientId, aesKey);
|
|
455
|
+
return { serverPublicKey: serverKeyPair.publicKeyB64, salt: saltB64 };
|
|
456
|
+
},
|
|
457
|
+
outboundTransformer: async ({ client, event, data }) => {
|
|
458
|
+
if (!client.encrypted) return null;
|
|
459
|
+
const aesKey = clientKeys.get(client.id);
|
|
460
|
+
if (!aesKey) return null;
|
|
461
|
+
const encrypted = await ecdh.encrypt({
|
|
462
|
+
message: JSON.stringify({ event, data }),
|
|
463
|
+
secret: aesKey,
|
|
464
|
+
});
|
|
465
|
+
return { event: 'encrypted', data: encrypted };
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
> [!IMPORTANT]
|
|
471
|
+
> When `requireEncryption` is `true`, `handshakeFn` **must** be provided. If it is missing, the server logs an error and closes the client with code `4004`.
|
|
472
|
+
|
|
473
|
+
#### Optional Encryption
|
|
474
|
+
|
|
475
|
+
If encryption is optional, call `enableClientEncryption()` manually after a key exchange in your `messageHandler`:
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
messageHandler: async ({ clientId, message }) => {
|
|
479
|
+
if (message.event === 'handshake') {
|
|
480
|
+
const peerKey = await ecdh.importPublicKey({ rawKeyB64: message.data.publicKey });
|
|
481
|
+
const aesKey = await ecdh.deriveAESKey({
|
|
482
|
+
privateKey: serverKeyPair.privateKey,
|
|
483
|
+
peerPublicKey: peerKey,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
clientKeys.set(clientId, aesKey);
|
|
487
|
+
helper.enableClientEncryption({ clientId });
|
|
488
|
+
|
|
489
|
+
helper.sendToClient({
|
|
490
|
+
clientId,
|
|
491
|
+
event: 'handshake-complete',
|
|
492
|
+
data: { publicKey: serverKeyPair.publicKeyB64 },
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
> [!IMPORTANT]
|
|
499
|
+
> When an `outboundTransformer` is configured, **Bun native pub/sub is bypassed** for `sendToRoom()` and `broadcast()`. All clients are iterated individually so the transformer runs per-client. This trades O(1) fan-out for per-client encryption capability.
|
|
500
|
+
|
|
501
|
+
### Redis Integration
|
|
502
|
+
|
|
503
|
+
The server creates **two** dedicated Redis connections (duplicated from your `redisConnection`):
|
|
504
|
+
|
|
505
|
+
| Connection | Purpose |
|
|
506
|
+
|------------|---------|
|
|
507
|
+
| `redisPub` | Publish messages to other server instances |
|
|
508
|
+
| `redisSub` | Subscribe to messages from other server instances |
|
|
509
|
+
|
|
510
|
+
```
|
|
511
|
+
Server A Redis Server B
|
|
512
|
+
+-----------+ +----------+ +-----------+
|
|
513
|
+
| WS Server |--redisPub-->| |<--redisPub----| WS Server |
|
|
514
|
+
| |<--redisSub--| Pub/Sub |---redisSub--->| |
|
|
515
|
+
+-----------+ +----------+ +-----------+
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Every server instance generates a unique `serverId` (UUID) at construction. Messages from the same server are skipped on receipt to prevent double delivery.
|
|
519
|
+
|
|
520
|
+
## Troubleshooting
|
|
521
|
+
|
|
522
|
+
### "Client disconnects immediately with close code 4001"
|
|
523
|
+
|
|
524
|
+
The client connects but is disconnected before it can interact.
|
|
525
|
+
|
|
526
|
+
This happens when the client does not send `{ event: 'authenticate', data: { ... } }` within `authTimeout` (default: 5 seconds). Common causes:
|
|
527
|
+
|
|
528
|
+
- The client is sending the auth payload in the wrong format (e.g., `{ type: 'auth' }` instead of `{ event: 'authenticate' }`).
|
|
529
|
+
- The client is waiting for a server-initiated message before authenticating. The server sends nothing after the WebSocket upgrade -- the client must initiate.
|
|
530
|
+
- Network latency or slow token retrieval causes the auth message to arrive after the timeout window.
|
|
531
|
+
|
|
532
|
+
> [!TIP]
|
|
533
|
+
> Send `{ event: 'authenticate', data: { token: '...' } }` immediately in the `onopen` handler. If your auth token retrieval is slow, increase `authTimeout` in the server options.
|
|
534
|
+
|
|
535
|
+
### "Redis subscription messages are not received across instances"
|
|
536
|
+
|
|
537
|
+
`helper.send()` delivers locally but other server instances do not receive the message.
|
|
538
|
+
|
|
539
|
+
- The Redis connection passed to `WebSocketServerHelper` is a single-instance `Redis` client, but your deployment uses Redis Cluster. The duplicated pub/sub clients must be compatible with your Redis topology.
|
|
540
|
+
- `configure()` was not awaited. The Redis subscriptions are set up asynchronously during `configure()`. If you accept WebSocket connections before it resolves, subscriptions may not be active.
|
|
541
|
+
- A firewall or Redis ACL is blocking `SUBSCRIBE`/`PSUBSCRIBE` commands on the duplicated clients.
|
|
542
|
+
|
|
543
|
+
> [!IMPORTANT]
|
|
544
|
+
> Always `await helper.configure()` before accepting connections. Verify your Redis connection supports pub/sub (check ACLs, ensure cluster mode is consistent).
|
|
545
|
+
|
|
546
|
+
### "`requireEncryption` is true but clients get disconnected with code 4004"
|
|
547
|
+
|
|
548
|
+
Authentication succeeds but the client is immediately closed with code `4004`.
|
|
549
|
+
|
|
550
|
+
- `handshakeFn` is not provided in the options. The server logs `"requireEncryption is true but no handshakeFn configured"` and closes the client.
|
|
551
|
+
- `handshakeFn` returns `null` or `false`, indicating the handshake was rejected (e.g., missing `publicKey` in the auth payload).
|
|
552
|
+
|
|
553
|
+
> [!TIP]
|
|
554
|
+
> Ensure `handshakeFn` is configured when `requireEncryption` is `true`, and that the client includes the required key exchange data (e.g., `publicKey`) in the authenticate payload.
|
|
555
|
+
|
|
556
|
+
### "[WebSocketServerHelper] Invalid redis connection!"
|
|
557
|
+
|
|
558
|
+
Thrown during construction when `redisConnection` is `null`/`undefined`. Ensure you pass a valid `DefaultRedisHelper` instance.
|
|
559
|
+
|
|
560
|
+
### "[WebSocketEmitter] Invalid redis connection!"
|
|
561
|
+
|
|
562
|
+
Thrown during `WebSocketEmitter` construction when `redisConnection` is `null`/`undefined`. Ensure you pass a valid `DefaultRedisHelper` instance.
|
|
563
|
+
|
|
564
|
+
### "Redis client did not become ready within 30000ms"
|
|
565
|
+
|
|
566
|
+
Thrown during `configure()` when a Redis client fails to reach `ready` status. Check that the Redis server is reachable and the parent `DefaultRedisHelper` connection is properly configured.
|
|
567
|
+
|
|
568
|
+
## See Also
|
|
569
|
+
|
|
570
|
+
- [API Reference](./api) -- Full method signatures, types, and constants
|
|
571
|
+
- [Socket.IO Helper](../socket-io/) -- Socket.IO-based alternative with Node.js support
|
|
572
|
+
- [Redis Helper](../redis/) -- `RedisHelper` used for cross-instance messaging
|
|
573
|
+
- [Crypto Helper](../crypto/) -- ECDH key exchange for WebSocket encryption
|
|
574
|
+
- [WebSocket Component](/references/components/websocket/) -- Component-level lifecycle integration
|