@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.
Files changed (98) hide show
  1. package/package.json +1 -1
  2. package/wiki/best-practices/architecture-decisions.md +0 -8
  3. package/wiki/best-practices/code-style-standards/control-flow.md +1 -1
  4. package/wiki/best-practices/performance-optimization.md +3 -3
  5. package/wiki/best-practices/security-guidelines.md +2 -2
  6. package/wiki/best-practices/troubleshooting-tips.md +1 -1
  7. package/wiki/guides/core-concepts/components-guide.md +1 -1
  8. package/wiki/guides/core-concepts/components.md +2 -2
  9. package/wiki/guides/core-concepts/dependency-injection.md +1 -1
  10. package/wiki/guides/core-concepts/services.md +1 -1
  11. package/wiki/guides/tutorials/building-a-crud-api.md +1 -1
  12. package/wiki/guides/tutorials/ecommerce-api.md +2 -2
  13. package/wiki/guides/tutorials/realtime-chat.md +6 -6
  14. package/wiki/guides/tutorials/testing.md +1 -1
  15. package/wiki/references/base/bootstrapping.md +0 -2
  16. package/wiki/references/base/components.md +2 -2
  17. package/wiki/references/base/controllers.md +0 -1
  18. package/wiki/references/base/datasources.md +1 -1
  19. package/wiki/references/base/dependency-injection.md +1 -1
  20. package/wiki/references/base/filter-system/quick-reference.md +0 -14
  21. package/wiki/references/base/middlewares.md +0 -8
  22. package/wiki/references/base/providers.md +0 -9
  23. package/wiki/references/base/services.md +0 -1
  24. package/wiki/references/components/authentication/api.md +444 -0
  25. package/wiki/references/components/authentication/errors.md +177 -0
  26. package/wiki/references/components/authentication/index.md +571 -0
  27. package/wiki/references/components/authentication/usage.md +781 -0
  28. package/wiki/references/components/health-check.md +292 -103
  29. package/wiki/references/components/index.md +14 -12
  30. package/wiki/references/components/mail/api.md +505 -0
  31. package/wiki/references/components/mail/errors.md +176 -0
  32. package/wiki/references/components/mail/index.md +535 -0
  33. package/wiki/references/components/mail/usage.md +404 -0
  34. package/wiki/references/components/request-tracker.md +229 -25
  35. package/wiki/references/components/socket-io/api.md +1051 -0
  36. package/wiki/references/components/socket-io/errors.md +119 -0
  37. package/wiki/references/components/socket-io/index.md +410 -0
  38. package/wiki/references/components/socket-io/usage.md +322 -0
  39. package/wiki/references/components/static-asset/api.md +261 -0
  40. package/wiki/references/components/static-asset/errors.md +89 -0
  41. package/wiki/references/components/static-asset/index.md +617 -0
  42. package/wiki/references/components/static-asset/usage.md +364 -0
  43. package/wiki/references/components/swagger.md +390 -110
  44. package/wiki/references/components/template/api-page.md +125 -0
  45. package/wiki/references/components/template/errors-page.md +100 -0
  46. package/wiki/references/components/template/index.md +104 -0
  47. package/wiki/references/components/template/setup-page.md +134 -0
  48. package/wiki/references/components/template/single-page.md +132 -0
  49. package/wiki/references/components/template/usage-page.md +127 -0
  50. package/wiki/references/components/websocket/api.md +508 -0
  51. package/wiki/references/components/websocket/errors.md +123 -0
  52. package/wiki/references/components/websocket/index.md +453 -0
  53. package/wiki/references/components/websocket/usage.md +475 -0
  54. package/wiki/references/helpers/cron/index.md +224 -0
  55. package/wiki/references/helpers/crypto/index.md +537 -0
  56. package/wiki/references/helpers/env/index.md +214 -0
  57. package/wiki/references/helpers/error/index.md +232 -0
  58. package/wiki/references/helpers/index.md +16 -15
  59. package/wiki/references/helpers/inversion/index.md +608 -0
  60. package/wiki/references/helpers/logger/index.md +600 -0
  61. package/wiki/references/helpers/network/api.md +986 -0
  62. package/wiki/references/helpers/network/index.md +620 -0
  63. package/wiki/references/helpers/queue/index.md +589 -0
  64. package/wiki/references/helpers/redis/index.md +495 -0
  65. package/wiki/references/helpers/socket-io/api.md +497 -0
  66. package/wiki/references/helpers/socket-io/index.md +513 -0
  67. package/wiki/references/helpers/storage/api.md +705 -0
  68. package/wiki/references/helpers/storage/index.md +583 -0
  69. package/wiki/references/helpers/template/index.md +66 -0
  70. package/wiki/references/helpers/template/single-page.md +126 -0
  71. package/wiki/references/helpers/testing/index.md +510 -0
  72. package/wiki/references/helpers/types/index.md +512 -0
  73. package/wiki/references/helpers/uid/index.md +272 -0
  74. package/wiki/references/helpers/websocket/api.md +736 -0
  75. package/wiki/references/helpers/websocket/index.md +574 -0
  76. package/wiki/references/helpers/worker-thread/index.md +470 -0
  77. package/wiki/references/quick-reference.md +3 -18
  78. package/wiki/references/utilities/jsx.md +1 -8
  79. package/wiki/references/utilities/statuses.md +0 -7
  80. package/wiki/references/components/authentication.md +0 -476
  81. package/wiki/references/components/mail.md +0 -687
  82. package/wiki/references/components/socket-io.md +0 -562
  83. package/wiki/references/components/static-asset.md +0 -1277
  84. package/wiki/references/helpers/cron.md +0 -108
  85. package/wiki/references/helpers/crypto.md +0 -132
  86. package/wiki/references/helpers/env.md +0 -83
  87. package/wiki/references/helpers/error.md +0 -97
  88. package/wiki/references/helpers/inversion.md +0 -176
  89. package/wiki/references/helpers/logger.md +0 -296
  90. package/wiki/references/helpers/network.md +0 -396
  91. package/wiki/references/helpers/queue.md +0 -150
  92. package/wiki/references/helpers/redis.md +0 -142
  93. package/wiki/references/helpers/socket-io.md +0 -932
  94. package/wiki/references/helpers/storage.md +0 -665
  95. package/wiki/references/helpers/testing.md +0 -133
  96. package/wiki/references/helpers/types.md +0 -167
  97. package/wiki/references/helpers/uid.md +0 -167
  98. 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