@venizia/ignis-docs 0.0.5 → 0.0.6-0
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/architecture-decisions.md +0 -8
- package/wiki/best-practices/code-style-standards/control-flow.md +1 -1
- 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/components-guide.md +1 -1
- package/wiki/guides/core-concepts/components.md +2 -2
- package/wiki/guides/core-concepts/dependency-injection.md +1 -1
- package/wiki/guides/core-concepts/services.md +1 -1
- package/wiki/guides/tutorials/building-a-crud-api.md +1 -1
- package/wiki/guides/tutorials/ecommerce-api.md +2 -2
- package/wiki/guides/tutorials/realtime-chat.md +6 -6
- package/wiki/guides/tutorials/testing.md +1 -1
- package/wiki/references/base/bootstrapping.md +0 -2
- 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 +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/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/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
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
# WebSocket -- Usage & Examples
|
|
2
|
+
|
|
3
|
+
> Server-side usage patterns, WebSocket Emitter, wire protocol, client tracking, Redis channel architecture, authentication flow, and delivery strategy.
|
|
4
|
+
|
|
5
|
+
## Using in Services/Controllers
|
|
6
|
+
|
|
7
|
+
Inject `WebSocketServerHelper` to interact with WebSocket:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import {
|
|
11
|
+
BaseService,
|
|
12
|
+
inject,
|
|
13
|
+
WebSocketBindingKeys,
|
|
14
|
+
CoreBindings,
|
|
15
|
+
BaseApplication,
|
|
16
|
+
} from '@venizia/ignis';
|
|
17
|
+
import { WebSocketServerHelper } from '@venizia/ignis-helpers';
|
|
18
|
+
|
|
19
|
+
export class NotificationService extends BaseService {
|
|
20
|
+
// Lazy getter pattern -- helper is bound AFTER server starts
|
|
21
|
+
private _ws: WebSocketServerHelper | null = null;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
@inject({ key: CoreBindings.APPLICATION_INSTANCE })
|
|
25
|
+
private application: BaseApplication,
|
|
26
|
+
) {
|
|
27
|
+
super({ scope: NotificationService.name });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private get ws(): WebSocketServerHelper {
|
|
31
|
+
if (!this._ws) {
|
|
32
|
+
this._ws = this.application.get<WebSocketServerHelper>({
|
|
33
|
+
key: WebSocketBindingKeys.WEBSOCKET_INSTANCE,
|
|
34
|
+
isOptional: true,
|
|
35
|
+
}) ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!this._ws) {
|
|
39
|
+
throw new Error('WebSocket not initialized');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return this._ws;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Send to a specific client
|
|
46
|
+
notifyClient(opts: { clientId: string; message: string }) {
|
|
47
|
+
this.ws.send({
|
|
48
|
+
destination: opts.clientId,
|
|
49
|
+
payload: {
|
|
50
|
+
topic: 'notification',
|
|
51
|
+
data: { message: opts.message, time: new Date().toISOString() },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Send to all sessions of a user (local instance only)
|
|
57
|
+
notifyUser(opts: { userId: string; message: string }) {
|
|
58
|
+
this.ws.sendToUser({
|
|
59
|
+
userId: opts.userId,
|
|
60
|
+
event: 'notification',
|
|
61
|
+
data: { message: opts.message },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Send to a room
|
|
66
|
+
notifyRoom(opts: { room: string; message: string }) {
|
|
67
|
+
this.ws.send({
|
|
68
|
+
destination: opts.room,
|
|
69
|
+
payload: {
|
|
70
|
+
topic: 'room:update',
|
|
71
|
+
data: { message: opts.message },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Broadcast to all clients
|
|
77
|
+
broadcastAnnouncement(opts: { message: string }) {
|
|
78
|
+
this.ws.send({
|
|
79
|
+
payload: {
|
|
80
|
+
topic: 'system:announcement',
|
|
81
|
+
data: { message: opts.message },
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
> [!IMPORTANT]
|
|
89
|
+
> **Lazy getter pattern**: Since `WebSocketServerHelper` is bound via a post-start hook, it is not available during DI construction. Use a lazy getter that resolves from the application container on first access.
|
|
90
|
+
|
|
91
|
+
> [!WARNING]
|
|
92
|
+
> **`send()` does not support cross-instance user targeting.** The `send()` method resolves `destination` by checking local `clients` map then local `rooms` map. There is no `USER` type in `send()`. To reach all sessions of a user across instances, use `sendToUser()` for local delivery or `WebSocketEmitter.toUser()` for Redis-based cross-instance delivery.
|
|
93
|
+
|
|
94
|
+
## WebSocket Emitter
|
|
95
|
+
|
|
96
|
+
`WebSocketEmitter` is a **standalone, lightweight Redis-only publisher** for sending WebSocket messages from processes that do not run a WebSocket server -- such as background workers, cron jobs, microservices, or CLI scripts.
|
|
97
|
+
|
|
98
|
+
It connects to Redis and publishes messages using the same `IRedisSocketMessage` envelope that `WebSocketServerHelper` listens for, so all connected server instances will receive and deliver the messages to their local clients.
|
|
99
|
+
|
|
100
|
+
#### When to Use WebSocketEmitter
|
|
101
|
+
|
|
102
|
+
| Scenario | Use |
|
|
103
|
+
|----------|-----|
|
|
104
|
+
| Send from a controller or service in the main app | `WebSocketServerHelper` (injected via DI) |
|
|
105
|
+
| Send from a background worker or cron job | `WebSocketEmitter` |
|
|
106
|
+
| Send from a separate microservice | `WebSocketEmitter` |
|
|
107
|
+
| Broadcast from a CLI script | `WebSocketEmitter` |
|
|
108
|
+
|
|
109
|
+
#### Emitter Setup
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
import { WebSocketEmitter, RedisHelper } from '@venizia/ignis-helpers';
|
|
113
|
+
|
|
114
|
+
// 1. Create a Redis connection (same Redis instance as the WebSocket server)
|
|
115
|
+
const redisHelper = new RedisHelper({
|
|
116
|
+
name: 'emitter-redis',
|
|
117
|
+
host: process.env.REDIS_HOST ?? 'localhost',
|
|
118
|
+
port: +(process.env.REDIS_PORT ?? 6379),
|
|
119
|
+
password: process.env.REDIS_PASSWORD,
|
|
120
|
+
autoConnect: false,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// 2. Create the emitter
|
|
124
|
+
const emitter = new WebSocketEmitter({
|
|
125
|
+
identifier: 'my-worker-emitter', // Optional, defaults to 'WebSocketEmitter'
|
|
126
|
+
redisConnection: redisHelper,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// 3. Configure (connects Redis pub client)
|
|
130
|
+
await emitter.configure();
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
#### Sending Messages
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// Send to a specific client by ID
|
|
137
|
+
await emitter.toClient({
|
|
138
|
+
clientId: 'uuid-of-client',
|
|
139
|
+
event: 'job:progress',
|
|
140
|
+
data: { jobId: '123', progress: 75 },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Send to all sessions of a user (cross-instance)
|
|
144
|
+
await emitter.toUser({
|
|
145
|
+
userId: 'user-456',
|
|
146
|
+
event: 'notification',
|
|
147
|
+
data: { message: 'Your report is ready' },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Send to a room
|
|
151
|
+
await emitter.toRoom({
|
|
152
|
+
room: 'dashboard-viewers',
|
|
153
|
+
event: 'data:update',
|
|
154
|
+
data: { metric: 'cpu', value: 42.5 },
|
|
155
|
+
exclude: ['client-id-to-skip'], // Optional: exclude specific clients
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Broadcast to all connected, authenticated clients
|
|
159
|
+
await emitter.broadcast({
|
|
160
|
+
event: 'system:maintenance',
|
|
161
|
+
data: { message: 'Scheduled maintenance in 10 minutes' },
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
#### Shutdown
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
// Always shut down when done to release the Redis connection
|
|
169
|
+
await emitter.shutdown();
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
> [!NOTE]
|
|
173
|
+
> The emitter uses a fixed `serverId` of `'emitter'` instead of a random UUID. This means all server instances will process emitter messages (none will self-dedup). The emitter only needs a single Redis client (pub), not two (pub + sub) like the server helper.
|
|
174
|
+
|
|
175
|
+
> [!TIP]
|
|
176
|
+
> `WebSocketEmitter.toUser()` publishes to the `ws:user:{userId}` Redis channel. All server instances subscribed via `psubscribe('ws:user:*')` will receive it and call `sendToUser()` locally, reaching every session of that user across all instances. This is the **recommended way** to send to a user from outside the main application process.
|
|
177
|
+
|
|
178
|
+
## Wire Protocol
|
|
179
|
+
|
|
180
|
+
### Client-Server Message Format
|
|
181
|
+
|
|
182
|
+
All messages exchanged between client and server follow the `IWebSocketMessage` envelope:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
interface IWebSocketMessage<DataType = unknown> {
|
|
186
|
+
event: string; // Event name (system or custom)
|
|
187
|
+
data?: DataType; // Payload data
|
|
188
|
+
id?: string; // Optional message ID (application-defined)
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Messages are serialized as JSON strings over the WebSocket connection. The `event` field is required -- messages without it are logged and dropped.
|
|
193
|
+
|
|
194
|
+
### System Events
|
|
195
|
+
|
|
196
|
+
| Event | Direction | Payload | Description |
|
|
197
|
+
|-------|-----------|---------|-------------|
|
|
198
|
+
| `authenticate` | Client --> Server | Auth credentials (<code v-pre>{ type, token, publicKey? }</code>) | Client sends credentials after connection opens |
|
|
199
|
+
| `connected` | Server --> Client | <code v-pre>{ id, userId, time, serverPublicKey?, salt? }</code> | Sent after successful authentication |
|
|
200
|
+
| `disconnect` | Both | -- | Connection closing |
|
|
201
|
+
| `join` | Client --> Server | <code v-pre>{ rooms: string[] }</code> | Request to join rooms |
|
|
202
|
+
| `leave` | Client --> Server | <code v-pre>{ rooms: string[] }</code> | Request to leave rooms |
|
|
203
|
+
| `error` | Server --> Client | <code v-pre>{ message: string }</code> | Error notification |
|
|
204
|
+
| `heartbeat` | Client --> Server | -- | Keep-alive ping (client sends, server updates `lastActivity`) |
|
|
205
|
+
| `encrypted` | Both | Varies | Encryption handshake data |
|
|
206
|
+
|
|
207
|
+
> [!NOTE]
|
|
208
|
+
> The `heartbeat` event is handled specially -- it updates the client's `lastActivity` timestamp and returns immediately without triggering any callbacks. Clients must send heartbeats within the `heartbeatTimeout` interval to avoid being disconnected with code `4002`.
|
|
209
|
+
|
|
210
|
+
### Close Codes
|
|
211
|
+
|
|
212
|
+
| Code | Reason | Trigger |
|
|
213
|
+
|------|--------|---------|
|
|
214
|
+
| `1001` | Server shutting down | `wsHelper.shutdown()` |
|
|
215
|
+
| `4001` | Authentication timeout | Client did not send `authenticate` within `authTimeout`, or `authenticateFn` did not complete within `authTimeout * 3` |
|
|
216
|
+
| `4002` | Heartbeat timeout | No messages received within `heartbeatTimeout` |
|
|
217
|
+
| `4003` | Authentication failed | `authenticateFn` returned `null`/`false` or threw an exception |
|
|
218
|
+
| `4004` | Encryption required | `requireEncryption: true` and either no `handshakeFn` configured or `handshakeFn` returned `null`/`false` |
|
|
219
|
+
|
|
220
|
+
### Redis Message Envelope
|
|
221
|
+
|
|
222
|
+
Cross-instance messages are published via Redis Pub/Sub using the `IRedisSocketMessage` envelope:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
interface IRedisSocketMessage<DataType = unknown> {
|
|
226
|
+
serverId: string; // Source server instance ID (UUID or 'emitter')
|
|
227
|
+
type: TWebSocketMessageType; // 'client' | 'user' | 'room' | 'broadcast'
|
|
228
|
+
target?: string; // Target clientId / userId / room name
|
|
229
|
+
event: string; // Event to deliver
|
|
230
|
+
data: DataType; // Payload
|
|
231
|
+
exclude?: string[]; // Client IDs to exclude from delivery
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Messages from the same `serverId` are ignored (self-dedup) -- the sending server already delivered locally before publishing to Redis. Messages from the `WebSocketEmitter` use `serverId = 'emitter'`, which never matches any server's UUID, so all servers process them.
|
|
236
|
+
|
|
237
|
+
### Message Types
|
|
238
|
+
|
|
239
|
+
| Type | Channel Pattern | Description |
|
|
240
|
+
|------|----------------|-------------|
|
|
241
|
+
| `client` | `ws:client:{clientId}` | Direct to specific client |
|
|
242
|
+
| `user` | `ws:user:{userId}` | To all clients of a user |
|
|
243
|
+
| `room` | `ws:room:{roomName}` | To all clients in a room |
|
|
244
|
+
| `broadcast` | `ws:broadcast` | To all connected, authenticated clients |
|
|
245
|
+
|
|
246
|
+
## Client Tracking
|
|
247
|
+
|
|
248
|
+
### `IWebSocketClient` Interface
|
|
249
|
+
|
|
250
|
+
Each connected client is tracked in an in-memory `Map<string, IWebSocketClient>`:
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
interface IWebSocketClient<
|
|
254
|
+
MetadataType extends Record<string, unknown> = Record<string, unknown>,
|
|
255
|
+
> {
|
|
256
|
+
id: string; // Unique client ID (UUID, assigned during upgrade)
|
|
257
|
+
userId?: string; // Set after authentication
|
|
258
|
+
socket: IWebSocket; // Bun native WebSocket reference
|
|
259
|
+
state: TWebSocketClientState; // 'unauthorized' | 'authenticating' | 'authenticated' | 'disconnected'
|
|
260
|
+
rooms: Set<string>; // Joined rooms (including default rooms and own clientId room)
|
|
261
|
+
backpressured: boolean; // True when socket.send() returns -1 (Bun backpressure)
|
|
262
|
+
encrypted: boolean; // Whether client has completed encryption handshake
|
|
263
|
+
connectedAt: number; // Connection timestamp (Date.now())
|
|
264
|
+
lastActivity: number; // Last heartbeat/message timestamp (Date.now())
|
|
265
|
+
metadata?: MetadataType; // Custom metadata from authenticateFn return value
|
|
266
|
+
serverPublicKey?: string; // ECDH public key (set if encrypted)
|
|
267
|
+
salt?: string; // Encryption salt (set if encrypted)
|
|
268
|
+
authTimer?: ReturnType<typeof setTimeout>; // Auth timeout timer (cleared after auth)
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Client State Transitions
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
UNAUTHORIZED --(authenticate event)--> AUTHENTICATING
|
|
276
|
+
|
|
|
277
|
+
+-------------+-------------+
|
|
278
|
+
| | |
|
|
279
|
+
auth fails auth succeeds timeout
|
|
280
|
+
| | |
|
|
281
|
+
v v v
|
|
282
|
+
DISCONNECTED AUTHENTICATED DISCONNECTED
|
|
283
|
+
|
|
|
284
|
+
(close / heartbeat timeout)
|
|
285
|
+
|
|
|
286
|
+
v
|
|
287
|
+
DISCONNECTED
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
States are defined in the `WebSocketClientStates` constant class:
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
class WebSocketClientStates {
|
|
294
|
+
static readonly UNAUTHORIZED = 'unauthorized';
|
|
295
|
+
static readonly AUTHENTICATING = 'authenticating';
|
|
296
|
+
static readonly AUTHENTICATED = 'authenticated';
|
|
297
|
+
static readonly DISCONNECTED = 'disconnected';
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Tracking Maps
|
|
302
|
+
|
|
303
|
+
The server maintains three index maps for efficient lookups:
|
|
304
|
+
|
|
305
|
+
| Map | Key | Value | Purpose |
|
|
306
|
+
|-----|-----|-------|---------|
|
|
307
|
+
| `clients` | `clientId` | `IWebSocketClient` | All connected clients |
|
|
308
|
+
| `users` | `userId` | `Set<clientId>` | Multi-session user index |
|
|
309
|
+
| `rooms` | `room` | `Set<clientId>` | Room membership index |
|
|
310
|
+
|
|
311
|
+
> [!TIP]
|
|
312
|
+
> A single user can have multiple client connections (e.g., browser tab + mobile). Use `getClientsByUser({ userId })` to reach all sessions. The `users` map entry is automatically cleaned up when the last client for a user disconnects.
|
|
313
|
+
|
|
314
|
+
## Redis Channel Architecture
|
|
315
|
+
|
|
316
|
+
### `WebSocketChannels` Class
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
class WebSocketChannels {
|
|
320
|
+
// --- Static channel names ---
|
|
321
|
+
static readonly BROADCAST = 'ws:broadcast';
|
|
322
|
+
static readonly ROOM_PREFIX = 'ws:room:';
|
|
323
|
+
static readonly CLIENT_PREFIX = 'ws:client:';
|
|
324
|
+
static readonly USER_PREFIX = 'ws:user:';
|
|
325
|
+
|
|
326
|
+
// --- Channel builders ---
|
|
327
|
+
static forRoom(opts: { room: string }): string; // 'ws:room:{room}'
|
|
328
|
+
static forClient(opts: { clientId: string }): string; // 'ws:client:{clientId}'
|
|
329
|
+
static forUser(opts: { userId: string }): string; // 'ws:user:{userId}'
|
|
330
|
+
|
|
331
|
+
// --- Pattern builders (for Redis PSUBSCRIBE) ---
|
|
332
|
+
static forRoomPattern(): string; // 'ws:room:*'
|
|
333
|
+
static forClientPattern(): string; // 'ws:client:*'
|
|
334
|
+
static forUserPattern(): string; // 'ws:user:*'
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Redis Client Type
|
|
339
|
+
|
|
340
|
+
Both `WebSocketServerHelper` and `WebSocketEmitter` support Redis single instance and Redis Cluster:
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
type TRedisClient = Redis | Cluster;
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The Redis client is obtained via `redisConnection.getClient().duplicate()`. The `duplicate()` call creates a fresh connection that inherits the parent's configuration (including cluster mode). This ensures WebSocket pub/sub traffic does not interfere with application Redis usage.
|
|
347
|
+
|
|
348
|
+
### Subscription Setup
|
|
349
|
+
|
|
350
|
+
During `configure()`, the server subscribes to all channels:
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
// Direct subscribe (exact match)
|
|
354
|
+
redisSub.subscribe(WebSocketChannels.BROADCAST); // 'ws:broadcast'
|
|
355
|
+
|
|
356
|
+
// Pattern subscribe (wildcard match)
|
|
357
|
+
redisSub.psubscribe(WebSocketChannels.forRoomPattern()); // 'ws:room:*'
|
|
358
|
+
redisSub.psubscribe(WebSocketChannels.forClientPattern()); // 'ws:client:*'
|
|
359
|
+
redisSub.psubscribe(WebSocketChannels.forUserPattern()); // 'ws:user:*'
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
> [!NOTE]
|
|
363
|
+
> Redis PSUBSCRIBE uses pattern matching -- a message published to `ws:room:chat-general` is received by all servers subscribed to `ws:room:*`. This allows the server to receive messages for any room without knowing room names in advance.
|
|
364
|
+
|
|
365
|
+
### Message Flow (Cross-Instance)
|
|
366
|
+
|
|
367
|
+
```
|
|
368
|
+
Server A Redis Server B
|
|
369
|
+
| | |
|
|
370
|
+
|-- send({ destination: room }) -| |
|
|
371
|
+
| 1. sendToRoom() locally | |
|
|
372
|
+
| 2. publishToRedis() -------->|-- ws:room:chat ------> |
|
|
373
|
+
| | onRedisMessage()
|
|
374
|
+
| | |-- skip if serverId === own
|
|
375
|
+
| | +-- sendToRoom() locally
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### Message Flow (Emitter to Servers)
|
|
379
|
+
|
|
380
|
+
```
|
|
381
|
+
WebSocketEmitter Redis Server A + Server B
|
|
382
|
+
| | |
|
|
383
|
+
|-- toUser({ userId }) -------->|-- ws:user:u1 --------> |
|
|
384
|
+
| serverId = 'emitter' | onRedisMessage()
|
|
385
|
+
| | |-- serverId !== own -> process
|
|
386
|
+
| | +-- sendToUser() locally
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## Authentication Flow
|
|
390
|
+
|
|
391
|
+
```
|
|
392
|
+
Client Server
|
|
393
|
+
| |
|
|
394
|
+
|-- WS upgrade request -------->|
|
|
395
|
+
|<-- 101 Switching Protocols ---| (Bun handles upgrade)
|
|
396
|
+
| |-- onClientConnect()
|
|
397
|
+
| | state = UNAUTHORIZED
|
|
398
|
+
| | subscribe(clientId) <-- Bun topic for direct messaging
|
|
399
|
+
| | start authTimer (5s default)
|
|
400
|
+
| |
|
|
401
|
+
|-- { event: 'authenticate', |
|
|
402
|
+
| data: { token: '...' } } >|-- handleAuthenticate()
|
|
403
|
+
| | state = AUTHENTICATING
|
|
404
|
+
| | replace timer with authTimeout * 3
|
|
405
|
+
| | await authenticateFn(data)
|
|
406
|
+
| | |
|
|
407
|
+
| | (if requireEncryption)
|
|
408
|
+
| | await handshakeFn(data)
|
|
409
|
+
| | enableClientEncryption()
|
|
410
|
+
| | state = AUTHENTICATED
|
|
411
|
+
| | index by userId
|
|
412
|
+
| | subscribe(BROADCAST_TOPIC) <-- unless encrypted
|
|
413
|
+
| | joinRoom(clientId) <-- auto-join own ID as room
|
|
414
|
+
| | joinRoom(default rooms)
|
|
415
|
+
| |
|
|
416
|
+
|<-- { event: 'connected', |
|
|
417
|
+
| data: { id, userId, |
|
|
418
|
+
| time, serverPublicKey?, |
|
|
419
|
+
| salt? } } -------------|
|
|
420
|
+
| |-- clientConnectedFn()
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### Authentication Timeout Details
|
|
424
|
+
|
|
425
|
+
There are two timeout phases:
|
|
426
|
+
|
|
427
|
+
1. **Initial timeout** (`authTimeout`, default 5 s): Starts when the client connects. If the client does not send an `authenticate` event within this window, the socket is closed with code `4001`.
|
|
428
|
+
|
|
429
|
+
2. **In-progress timeout** (`authTimeout * 3`, default 15 s): Replaces the initial timer when the `authenticate` event is received. This provides a longer window for the async `authenticateFn` (and optionally `handshakeFn`) to complete. If authentication does not finish within this window, the socket is closed with code `4001`.
|
|
430
|
+
|
|
431
|
+
### Client ID Auto-Join
|
|
432
|
+
|
|
433
|
+
After successful authentication, the server calls `joinRoom({ clientId, room: clientId })`. This means the client's own ID is registered as both a Bun native topic subscription (set during `onClientConnect`) and an application-level room. This enables targeting a specific client via `send({ destination: clientId })` or `sendToRoom({ room: clientId })`.
|
|
434
|
+
|
|
435
|
+
### Bun Topic Subscription Timing
|
|
436
|
+
|
|
437
|
+
| Topic | Subscribed At | Condition |
|
|
438
|
+
|-------|--------------|-----------|
|
|
439
|
+
| Client's own `clientId` | `onClientConnect()` (before auth) | Always |
|
|
440
|
+
| `BROADCAST_TOPIC` | `handleAuthenticate()` (after auth) | Only if `!client.encrypted` |
|
|
441
|
+
| Default rooms | `handleAuthenticate()` (after auth, via `joinRoom()`) | Only if `!client.encrypted` |
|
|
442
|
+
| Custom rooms | `handleJoin()` (on client request) | Only if `!client.encrypted` |
|
|
443
|
+
|
|
444
|
+
Encrypted clients are **never** subscribed to Bun native topics (except `clientId` which is set before encryption status is known). All delivery to encrypted clients goes through the per-client `outboundTransformer` path.
|
|
445
|
+
|
|
446
|
+
## Delivery Strategy
|
|
447
|
+
|
|
448
|
+
The helper uses a dual delivery strategy depending on whether encryption is active:
|
|
449
|
+
|
|
450
|
+
**Without encryption (fast path):**
|
|
451
|
+
- Room/broadcast messages use Bun's native `server.publish(topic, payload)` -- O(1) C++ fan-out
|
|
452
|
+
- Client-direct messages use `socket.send()` directly
|
|
453
|
+
- Zero JavaScript iteration for room fan-out
|
|
454
|
+
|
|
455
|
+
**With encryption (per-client path):**
|
|
456
|
+
- Encrypted clients are unsubscribed from all Bun native topics (`enableClientEncryption()`)
|
|
457
|
+
- Room/broadcast messages iterate clients individually, running each through `outboundTransformer`
|
|
458
|
+
- Uses `executePromiseWithLimit({ tasks, limit: encryptedBatchLimit })` for concurrency control
|
|
459
|
+
- Non-encrypted clients in the same room still use the Bun fast path
|
|
460
|
+
|
|
461
|
+
**With `exclude` parameter:**
|
|
462
|
+
- When `exclude` is provided in `sendToRoom()` or `broadcast()`, the fast path is bypassed even without encryption
|
|
463
|
+
- The server iterates all clients, skipping those in the `exclude` set
|
|
464
|
+
|
|
465
|
+
> [!IMPORTANT]
|
|
466
|
+
> When an `outboundTransformer` is bound, **all** room/broadcast messages fall back to the per-client iteration path (even for non-encrypted clients in the same room). This is because Bun native pub/sub cannot selectively apply transformations. Only bind `outboundTransformer` when you actually need per-client message transformation.
|
|
467
|
+
|
|
468
|
+
## See Also
|
|
469
|
+
|
|
470
|
+
- [Setup & Configuration](./) - Quick reference, imports, setup steps, configuration, and binding keys
|
|
471
|
+
- [API Reference](./api) - Architecture, WebSocketEmitter API, and internals
|
|
472
|
+
- [Error Reference](./errors) - Error conditions table and troubleshooting
|
|
473
|
+
- [WebSocketServerHelper](/references/helpers/websocket/) - Helper API documentation
|
|
474
|
+
- [Socket.IO Component](../socket-io/) - Node.js-compatible alternative with Socket.IO
|
|
475
|
+
- [Bun WebSocket Documentation](https://bun.sh/docs/api/websockets) - Official Bun WebSocket API reference
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Cron
|
|
2
|
+
|
|
3
|
+
Schedule and manage recurring tasks using cron expressions, with support for dynamic rescheduling and job duplication.
|
|
4
|
+
|
|
5
|
+
## Quick Reference
|
|
6
|
+
|
|
7
|
+
| Item | Value |
|
|
8
|
+
|------|-------|
|
|
9
|
+
| **Package** | `@venizia/ignis-helpers` |
|
|
10
|
+
| **Class** | `CronHelper` |
|
|
11
|
+
| **Extends** | `BaseHelper` |
|
|
12
|
+
| **Peer Dependency** | `cron` (^4.3.3, optional) |
|
|
13
|
+
| **Runtimes** | Both |
|
|
14
|
+
|
|
15
|
+
#### Import Paths
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { CronHelper } from '@venizia/ignis-helpers/cron';
|
|
19
|
+
import type { ICronHelperOptions } from '@venizia/ignis-helpers/cron';
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Creating an Instance
|
|
23
|
+
|
|
24
|
+
`CronHelper` wraps the `cron` package's `CronJob` class, adding scoped logging via `BaseHelper` and convenience methods for rescheduling and duplication.
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { CronHelper } from '@venizia/ignis-helpers/cron';
|
|
28
|
+
|
|
29
|
+
const job = new CronHelper({
|
|
30
|
+
cronTime: '0 */5 * * * *', // Every 5 minutes
|
|
31
|
+
onTick: async () => {
|
|
32
|
+
console.log('Running scheduled task');
|
|
33
|
+
},
|
|
34
|
+
autoStart: true,
|
|
35
|
+
tz: 'America/New_York',
|
|
36
|
+
errorHandler: (error) => {
|
|
37
|
+
console.error('Cron job failed:', error);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
> [!TIP]
|
|
43
|
+
> You can also use the static factory method `CronHelper.newInstance(opts)` which is equivalent to `new CronHelper(opts)`.
|
|
44
|
+
|
|
45
|
+
#### ICronHelperOptions
|
|
46
|
+
|
|
47
|
+
| Option | Type | Default | Description |
|
|
48
|
+
|--------|------|---------|-------------|
|
|
49
|
+
| `cronTime` | `string` | -- | Cron pattern defining when the job runs (e.g., `'0 */1 * * * *'`). Required, must be non-empty. |
|
|
50
|
+
| `onTick` | `() => void \| Promise<void>` | -- | Function executed each time the cron job triggers. Required. |
|
|
51
|
+
| `onCompleted` | `CronOnCompleteCommand \| null` | `undefined` | Callback executed when the job is stopped via `stop()`. |
|
|
52
|
+
| `autoStart` | `boolean` | `false` | If `true`, the job starts running immediately after construction. |
|
|
53
|
+
| `tz` | `string` | `undefined` | IANA timezone for the schedule (e.g., `'Asia/Ho_Chi_Minh'`). Uses server timezone if omitted. |
|
|
54
|
+
| `errorHandler` | `(error: unknown) => void \| null` | `undefined` | Handler invoked if `onTick` throws during execution. |
|
|
55
|
+
|
|
56
|
+
#### Common Cron Patterns
|
|
57
|
+
|
|
58
|
+
| Pattern | Description |
|
|
59
|
+
|---------|-------------|
|
|
60
|
+
| `'0 */1 * * * *'` | Every minute |
|
|
61
|
+
| `'0 */5 * * * *'` | Every 5 minutes |
|
|
62
|
+
| `'0 0 * * * *'` | Every hour |
|
|
63
|
+
| `'0 0 0 * * *'` | Every day at midnight |
|
|
64
|
+
| `'0 0 9 * * 1-5'` | Weekdays at 9 AM |
|
|
65
|
+
| `'0 0 0 * * 1'` | Every Monday at midnight |
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
### Scheduling Jobs
|
|
70
|
+
|
|
71
|
+
Create a job with `autoStart: true` to begin execution immediately, or leave it as `false` (default) and call `start()` when ready.
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
// Auto-start: begins running on schedule immediately
|
|
75
|
+
const autoJob = new CronHelper({
|
|
76
|
+
cronTime: '0 */1 * * * *',
|
|
77
|
+
onTick: () => {
|
|
78
|
+
console.log('Runs every minute');
|
|
79
|
+
},
|
|
80
|
+
autoStart: true,
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Starting Jobs Manually
|
|
85
|
+
|
|
86
|
+
When `autoStart` is `false`, the job is created but does not run until `start()` is called. This is useful when you need to set up dependencies before the job begins firing.
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
const job = new CronHelper({
|
|
90
|
+
cronTime: '0 0 * * * *', // Every hour
|
|
91
|
+
onTick: () => {
|
|
92
|
+
console.log('Hourly task executed');
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Start later when conditions are met
|
|
97
|
+
job.start();
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
If the internal `CronJob` instance does not exist (e.g., `configure()` failed), `start()` logs an error and returns without throwing.
|
|
101
|
+
|
|
102
|
+
### Modifying the Schedule
|
|
103
|
+
|
|
104
|
+
Change a job's cron schedule at runtime with `modifyCronTime()`. The job continues running with the new schedule.
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
modifyCronTime(opts: { cronTime: string; shouldFireOnTick?: boolean }): void
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
| Parameter | Type | Default | Description |
|
|
111
|
+
|-----------|------|---------|-------------|
|
|
112
|
+
| `cronTime` | `string` | -- | The new cron pattern to apply. |
|
|
113
|
+
| `shouldFireOnTick` | `boolean` | `false` | If `true`, immediately fires the `onTick` function after changing the schedule. |
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
// Change the job to run every 5 minutes instead
|
|
117
|
+
job.modifyCronTime({ cronTime: '0 */5 * * * *' });
|
|
118
|
+
|
|
119
|
+
// Change schedule and immediately fire onTick
|
|
120
|
+
job.modifyCronTime({ cronTime: '0 */10 * * * *', shouldFireOnTick: true });
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Duplicating Jobs
|
|
124
|
+
|
|
125
|
+
Create a new `CronHelper` instance that copies the current job's configuration (`onTick`, `onCompleted`, `autoStart`, `tz`, `errorHandler`) but uses a different `cronTime`.
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
duplicate(opts: { cronTime: string }): CronHelper
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
const dailyJob = new CronHelper({
|
|
133
|
+
cronTime: '0 0 0 * * *', // Daily at midnight
|
|
134
|
+
onTick: async () => {
|
|
135
|
+
await generateReport();
|
|
136
|
+
},
|
|
137
|
+
tz: 'America/New_York',
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Same logic, different schedule
|
|
141
|
+
const hourlyJob = dailyJob.duplicate({ cronTime: '0 0 * * * *' });
|
|
142
|
+
hourlyJob.start();
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
> [!NOTE]
|
|
146
|
+
> `duplicate()` copies all configuration except `cronTime`. The new instance is independent -- stopping or modifying one does not affect the other.
|
|
147
|
+
|
|
148
|
+
### Accessing the Underlying CronJob
|
|
149
|
+
|
|
150
|
+
The `instance` property exposes the underlying `CronJob` from the `cron` package, giving access to the full API (e.g., `stop()`, `running`, `lastDate()`).
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
const job = new CronHelper({
|
|
154
|
+
cronTime: '0 */1 * * * *',
|
|
155
|
+
onTick: () => { /* ... */ },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
job.start();
|
|
159
|
+
|
|
160
|
+
// Access the underlying CronJob directly
|
|
161
|
+
console.log(job.instance.running); // true
|
|
162
|
+
job.instance.stop();
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Troubleshooting
|
|
166
|
+
|
|
167
|
+
### "[CronHelper][configure] Invalid cronTime to configure application cron!"
|
|
168
|
+
|
|
169
|
+
**Cause:** The `cronTime` option is empty, undefined, or not provided.
|
|
170
|
+
|
|
171
|
+
**Fix:** Provide a valid, non-empty cron pattern string:
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
const job = new CronHelper({
|
|
175
|
+
cronTime: '0 */1 * * * *', // Must be a non-empty cron pattern
|
|
176
|
+
onTick: () => { /* ... */ },
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### "Invalid cron instance to start cronjob!"
|
|
181
|
+
|
|
182
|
+
**Cause:** `start()` was called but the internal `CronJob` instance was not created. This typically means `configure()` threw an error during construction.
|
|
183
|
+
|
|
184
|
+
**Fix:** Ensure the constructor completed without errors before calling `start()`. Wrap creation in a try/catch to detect configuration failures:
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
try {
|
|
188
|
+
const job = new CronHelper({
|
|
189
|
+
cronTime: '0 */1 * * * *',
|
|
190
|
+
onTick: () => { /* ... */ },
|
|
191
|
+
});
|
|
192
|
+
job.start();
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.error('Failed to create cron job:', error);
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Job does not fire at expected times
|
|
199
|
+
|
|
200
|
+
**Cause:** The `tz` option is not set or uses an incorrect timezone identifier, causing the job to fire based on the server's local timezone.
|
|
201
|
+
|
|
202
|
+
**Fix:** Set `tz` to the correct [IANA timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones):
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
const job = new CronHelper({
|
|
206
|
+
cronTime: '0 0 9 * * *', // 9 AM
|
|
207
|
+
onTick: () => { /* ... */ },
|
|
208
|
+
tz: 'America/New_York', // Explicit timezone
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## See Also
|
|
213
|
+
|
|
214
|
+
- **Related Concepts:**
|
|
215
|
+
- [Services](/guides/core-concepts/services) -- Scheduling jobs within services
|
|
216
|
+
- [Application](/guides/core-concepts/application/) -- Scheduling on application startup
|
|
217
|
+
|
|
218
|
+
- **Other Helpers:**
|
|
219
|
+
- [Helpers Index](../index) -- All available helpers
|
|
220
|
+
- [Queue Helper](../queue/) -- Message queue processing
|
|
221
|
+
|
|
222
|
+
- **External Resources:**
|
|
223
|
+
- [Cron Expression Guide](https://crontab.guru/) -- Interactive cron syntax reference
|
|
224
|
+
- [`cron` npm package](https://github.com/kelektiv/node-cron) -- Underlying cron library
|