@veloxts/events 0.6.51

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/GUIDE.md ADDED
@@ -0,0 +1,199 @@
1
+ # @veloxts/events Guide
2
+
3
+ ## Drivers
4
+
5
+ ### WebSocket Driver
6
+
7
+ Real-time broadcasting via WebSocket. Supports Redis pub/sub for horizontal scaling.
8
+
9
+ ```typescript
10
+ import { eventsPlugin } from '@veloxts/events';
11
+
12
+ // Basic setup (single instance)
13
+ app.use(eventsPlugin({
14
+ driver: 'ws',
15
+ config: {
16
+ path: '/ws',
17
+ pingInterval: 30000,
18
+ },
19
+ }));
20
+
21
+ // With Redis for horizontal scaling
22
+ app.use(eventsPlugin({
23
+ driver: 'ws',
24
+ config: {
25
+ path: '/ws',
26
+ redis: process.env.REDIS_URL,
27
+ },
28
+ }));
29
+ ```
30
+
31
+ ## Broadcasting Events
32
+
33
+ ```typescript
34
+ // Broadcast to a channel
35
+ await ctx.broadcast({
36
+ channel: 'orders.123',
37
+ event: 'order.shipped',
38
+ data: { trackingNumber: 'TRACK123' },
39
+ });
40
+
41
+ // Broadcast to all except sender
42
+ await ctx.broadcast({
43
+ channel: 'chat.room-1',
44
+ event: 'message',
45
+ data: { text: 'Hello!' },
46
+ except: ctx.socketId, // Exclude sender
47
+ });
48
+ ```
49
+
50
+ ## Channel Types
51
+
52
+ ### Public Channels
53
+
54
+ Anyone can subscribe.
55
+
56
+ ```typescript
57
+ // Server
58
+ await ctx.broadcast({
59
+ channel: 'announcements',
60
+ event: 'new-feature',
61
+ data: { title: 'New Feature!' },
62
+ });
63
+
64
+ // Client
65
+ socket.subscribe('announcements');
66
+ ```
67
+
68
+ ### Private Channels
69
+
70
+ Require authorization. Prefix with `private-`.
71
+
72
+ ```typescript
73
+ // Server - requires authorization middleware
74
+ await ctx.broadcast({
75
+ channel: 'private-user.123',
76
+ event: 'notification',
77
+ data: { message: 'You have a new message' },
78
+ });
79
+ ```
80
+
81
+ ### Presence Channels
82
+
83
+ Track who's online. Prefix with `presence-`.
84
+
85
+ ```typescript
86
+ // Server
87
+ await ctx.broadcast({
88
+ channel: 'presence-chat.room-1',
89
+ event: 'typing',
90
+ data: { userId: '123' },
91
+ });
92
+
93
+ // Client receives member_added/member_removed events automatically
94
+ ```
95
+
96
+ ## Client Integration
97
+
98
+ ### Browser Client
99
+
100
+ ```typescript
101
+ const socket = new WebSocket('ws://localhost:3030/ws');
102
+
103
+ socket.onopen = () => {
104
+ // Subscribe to channel
105
+ socket.send(JSON.stringify({
106
+ type: 'subscribe',
107
+ channel: 'orders.123',
108
+ }));
109
+ };
110
+
111
+ socket.onmessage = (event) => {
112
+ const message = JSON.parse(event.data);
113
+
114
+ if (message.type === 'event') {
115
+ console.log(`${message.event}:`, message.data);
116
+ }
117
+ };
118
+
119
+ // Unsubscribe
120
+ socket.send(JSON.stringify({
121
+ type: 'unsubscribe',
122
+ channel: 'orders.123',
123
+ }));
124
+ ```
125
+
126
+ ### Presence Channels (Client)
127
+
128
+ ```typescript
129
+ // Join with user info
130
+ socket.send(JSON.stringify({
131
+ type: 'subscribe',
132
+ channel: 'presence-chat.room-1',
133
+ data: { id: '123', name: 'John' },
134
+ }));
135
+
136
+ // Handle presence events
137
+ socket.onmessage = (event) => {
138
+ const message = JSON.parse(event.data);
139
+
140
+ if (message.event === 'member_added') {
141
+ console.log('User joined:', message.data);
142
+ }
143
+ if (message.event === 'member_removed') {
144
+ console.log('User left:', message.data);
145
+ }
146
+ };
147
+ ```
148
+
149
+ ## Server API
150
+
151
+ ```typescript
152
+ // Get subscribers for a channel
153
+ const subscribers = await ctx.events.getSubscribers('orders.123');
154
+
155
+ // Get presence members
156
+ const members = await ctx.events.getPresenceMembers('presence-chat.room-1');
157
+
158
+ // Get connection count
159
+ const count = await ctx.events.getConnectionCount('orders.123');
160
+
161
+ // Get all active channels
162
+ const channels = await ctx.events.getChannels();
163
+ ```
164
+
165
+ ## HTTP Upgrade (Manual Setup)
166
+
167
+ If not using the plugin, handle WebSocket upgrade manually:
168
+
169
+ ```typescript
170
+ import { createWsDriver } from '@veloxts/events';
171
+
172
+ const events = await createWsDriver({ path: '/ws' });
173
+
174
+ // In your HTTP server
175
+ server.on('upgrade', (request, socket, head) => {
176
+ if (request.url === '/ws') {
177
+ events.handleUpgrade(request, socket, head);
178
+ }
179
+ });
180
+ ```
181
+
182
+ ## Scaling with Redis
183
+
184
+ For multi-instance deployments, events are broadcast via Redis pub/sub:
185
+
186
+ ```typescript
187
+ app.use(eventsPlugin({
188
+ driver: 'ws',
189
+ config: {
190
+ path: '/ws',
191
+ redis: process.env.REDIS_URL,
192
+ },
193
+ }));
194
+ ```
195
+
196
+ All instances subscribe to a shared Redis channel. When you broadcast:
197
+ 1. Event is sent to local WebSocket clients
198
+ 2. Event is published to Redis
199
+ 3. Other instances receive from Redis and broadcast to their clients
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 VeloxTS Framework Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @veloxts/events
2
+
3
+ > **Early Preview** - APIs may change before v1.0.
4
+
5
+ Real-time event broadcasting via WebSocket with optional Redis pub/sub for scaling.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @veloxts/events
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```typescript
16
+ import { eventsPlugin } from '@veloxts/events';
17
+
18
+ app.use(eventsPlugin({ driver: 'ws', path: '/ws' }));
19
+
20
+ // Broadcast an event
21
+ await ctx.broadcast({
22
+ channel: 'orders.123',
23
+ event: 'order.shipped',
24
+ data: { trackingNumber: 'TRACK123' },
25
+ });
26
+ ```
27
+
28
+ See [GUIDE.md](./GUIDE.md) for detailed documentation.
29
+
30
+ ## License
31
+
32
+ MIT
package/dist/auth.d.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Channel Authentication
3
+ *
4
+ * HMAC-SHA256 signing for secure channel authorization.
5
+ * Prevents forgery of auth tokens for private/presence channels.
6
+ */
7
+ /**
8
+ * Channel authentication signature generator.
9
+ * Uses HMAC-SHA256 for cryptographic security.
10
+ */
11
+ export interface ChannelAuthSigner {
12
+ /**
13
+ * Generate a signed authentication token for a channel subscription.
14
+ *
15
+ * @param socketId - The unique socket identifier
16
+ * @param channel - The channel name being subscribed to
17
+ * @param channelData - Optional presence channel member data (JSON string)
18
+ * @returns The HMAC signature (hex-encoded)
19
+ */
20
+ sign(socketId: string, channel: string, channelData?: string): string;
21
+ /**
22
+ * Verify a signature is valid for the given parameters.
23
+ * Uses timing-safe comparison to prevent timing attacks.
24
+ *
25
+ * @param signature - The signature to verify
26
+ * @param socketId - The socket ID
27
+ * @param channel - The channel name
28
+ * @param channelData - Optional presence channel data
29
+ * @returns true if signature is valid
30
+ */
31
+ verify(signature: string, socketId: string, channel: string, channelData?: string): boolean;
32
+ }
33
+ /**
34
+ * Create a channel authentication signer with HMAC-SHA256.
35
+ *
36
+ * @param secret - The secret key (minimum 16 characters)
37
+ * @returns A signer instance
38
+ * @throws If secret is too short
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * const signer = createChannelAuthSigner(process.env.EVENTS_SECRET!);
43
+ *
44
+ * // Generate auth for private channel
45
+ * const signature = signer.sign('socket123', 'private-user-42');
46
+ *
47
+ * // Verify auth token
48
+ * const isValid = signer.verify(signature, 'socket123', 'private-user-42');
49
+ * ```
50
+ */
51
+ export declare function createChannelAuthSigner(secret: string): ChannelAuthSigner;
package/dist/auth.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Channel Authentication
3
+ *
4
+ * HMAC-SHA256 signing for secure channel authorization.
5
+ * Prevents forgery of auth tokens for private/presence channels.
6
+ */
7
+ import { createHmac, timingSafeEqual } from 'node:crypto';
8
+ /**
9
+ * Minimum secret length for security.
10
+ */
11
+ const MIN_SECRET_LENGTH = 16;
12
+ /**
13
+ * Create a channel authentication signer with HMAC-SHA256.
14
+ *
15
+ * @param secret - The secret key (minimum 16 characters)
16
+ * @returns A signer instance
17
+ * @throws If secret is too short
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * const signer = createChannelAuthSigner(process.env.EVENTS_SECRET!);
22
+ *
23
+ * // Generate auth for private channel
24
+ * const signature = signer.sign('socket123', 'private-user-42');
25
+ *
26
+ * // Verify auth token
27
+ * const isValid = signer.verify(signature, 'socket123', 'private-user-42');
28
+ * ```
29
+ */
30
+ export function createChannelAuthSigner(secret) {
31
+ if (secret.length < MIN_SECRET_LENGTH) {
32
+ throw new Error(`Auth secret must be at least ${MIN_SECRET_LENGTH} characters for security`);
33
+ }
34
+ /**
35
+ * Create the string to sign.
36
+ * Format: socketId:channel or socketId:channel:channelData
37
+ */
38
+ function createStringToSign(socketId, channel, channelData) {
39
+ const base = `${socketId}:${channel}`;
40
+ return channelData ? `${base}:${channelData}` : base;
41
+ }
42
+ /**
43
+ * Generate HMAC-SHA256 signature.
44
+ */
45
+ function generateSignature(stringToSign) {
46
+ return createHmac('sha256', secret).update(stringToSign, 'utf8').digest('hex');
47
+ }
48
+ return {
49
+ sign(socketId, channel, channelData) {
50
+ const stringToSign = createStringToSign(socketId, channel, channelData);
51
+ return generateSignature(stringToSign);
52
+ },
53
+ verify(signature, socketId, channel, channelData) {
54
+ const stringToSign = createStringToSign(socketId, channel, channelData);
55
+ const expectedSignature = generateSignature(stringToSign);
56
+ // Use timing-safe comparison to prevent timing attacks
57
+ try {
58
+ const signatureBuffer = Buffer.from(signature, 'hex');
59
+ const expectedBuffer = Buffer.from(expectedSignature, 'hex');
60
+ if (signatureBuffer.length !== expectedBuffer.length) {
61
+ return false;
62
+ }
63
+ return timingSafeEqual(signatureBuffer, expectedBuffer);
64
+ }
65
+ catch {
66
+ // Invalid hex string or other error
67
+ return false;
68
+ }
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Server-Sent Events (SSE) Driver
3
+ *
4
+ * Fallback driver for environments without WebSocket support.
5
+ * Uses HTTP streaming for server-to-client communication.
6
+ */
7
+ import type { FastifyReply, FastifyRequest } from 'fastify';
8
+ import type { BroadcastDriver, EventsSseOptions, PresenceMember } from '../types.js';
9
+ /**
10
+ * Create an SSE broadcast driver.
11
+ *
12
+ * @param config - SSE driver configuration
13
+ * @returns Broadcast driver implementation with handler
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * const driver = createSseDriver({
18
+ * driver: 'sse',
19
+ * path: '/events',
20
+ * heartbeatInterval: 15000,
21
+ * });
22
+ *
23
+ * // Register the SSE endpoint
24
+ * app.get('/events', driver.handler);
25
+ *
26
+ * // Broadcast events
27
+ * await driver.broadcast({
28
+ * channel: 'notifications',
29
+ * event: 'new_message',
30
+ * data: { text: 'Hello!' },
31
+ * });
32
+ * ```
33
+ */
34
+ export declare function createSseDriver(config: EventsSseOptions): BroadcastDriver & {
35
+ handler: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
36
+ subscribe: (connectionId: string, channel: string, member?: PresenceMember) => void;
37
+ unsubscribe: (connectionId: string, channel: string) => void;
38
+ };
39
+ /**
40
+ * SSE driver name.
41
+ */
42
+ export declare const DRIVER_NAME: "sse";
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Server-Sent Events (SSE) Driver
3
+ *
4
+ * Fallback driver for environments without WebSocket support.
5
+ * Uses HTTP streaming for server-to-client communication.
6
+ */
7
+ /**
8
+ * Default SSE driver configuration.
9
+ */
10
+ const DEFAULT_CONFIG = {
11
+ path: '/events',
12
+ heartbeatInterval: 15000,
13
+ retryInterval: 3000,
14
+ pingInterval: 30000,
15
+ connectionTimeout: 60000,
16
+ };
17
+ /**
18
+ * Create an SSE broadcast driver.
19
+ *
20
+ * @param config - SSE driver configuration
21
+ * @returns Broadcast driver implementation with handler
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * const driver = createSseDriver({
26
+ * driver: 'sse',
27
+ * path: '/events',
28
+ * heartbeatInterval: 15000,
29
+ * });
30
+ *
31
+ * // Register the SSE endpoint
32
+ * app.get('/events', driver.handler);
33
+ *
34
+ * // Broadcast events
35
+ * await driver.broadcast({
36
+ * channel: 'notifications',
37
+ * event: 'new_message',
38
+ * data: { text: 'Hello!' },
39
+ * });
40
+ * ```
41
+ */
42
+ export function createSseDriver(config) {
43
+ const options = { ...DEFAULT_CONFIG, ...config };
44
+ const { heartbeatInterval, retryInterval } = options;
45
+ // Connection tracking
46
+ const connections = new Map();
47
+ // Channel subscriptions: channel -> Set of connection IDs
48
+ const channelSubscriptions = new Map();
49
+ // Presence data: channel -> Map of connection ID -> PresenceMember
50
+ const presenceData = new Map();
51
+ /**
52
+ * Generate a unique connection ID.
53
+ */
54
+ function generateConnectionId() {
55
+ return `sse-${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 10)}`;
56
+ }
57
+ /**
58
+ * Send an SSE message to a connection.
59
+ */
60
+ function send(conn, message) {
61
+ try {
62
+ const data = JSON.stringify(message);
63
+ conn.reply.raw.write(`event: ${message.type}\n`);
64
+ conn.reply.raw.write(`data: ${data}\n\n`);
65
+ conn.lastActivity = new Date();
66
+ }
67
+ catch {
68
+ // Connection may be closed, ignore
69
+ }
70
+ }
71
+ /**
72
+ * Send a heartbeat comment to keep connection alive.
73
+ */
74
+ function sendHeartbeat(conn) {
75
+ try {
76
+ conn.reply.raw.write(`: heartbeat ${Date.now()}\n\n`);
77
+ conn.lastActivity = new Date();
78
+ }
79
+ catch {
80
+ // Connection closed, will be cleaned up
81
+ }
82
+ }
83
+ /**
84
+ * Broadcast an event to subscribers.
85
+ */
86
+ function broadcastLocal(event) {
87
+ const subscribers = channelSubscriptions.get(event.channel);
88
+ if (!subscribers)
89
+ return;
90
+ const message = {
91
+ type: 'event',
92
+ channel: event.channel,
93
+ event: event.event,
94
+ data: event.data,
95
+ };
96
+ for (const connId of subscribers) {
97
+ if (event.except && connId === event.except)
98
+ continue;
99
+ const conn = connections.get(connId);
100
+ if (conn) {
101
+ send(conn, message);
102
+ }
103
+ }
104
+ }
105
+ /**
106
+ * Subscribe a connection to a channel.
107
+ */
108
+ function subscribe(connectionId, channel, member) {
109
+ const conn = connections.get(connectionId);
110
+ if (!conn)
111
+ return;
112
+ // Add to connection's channels
113
+ conn.channels.add(channel);
114
+ // Add to channel subscriptions
115
+ let subscribers = channelSubscriptions.get(channel);
116
+ if (!subscribers) {
117
+ subscribers = new Set();
118
+ channelSubscriptions.set(channel, subscribers);
119
+ }
120
+ subscribers.add(connectionId);
121
+ // Handle presence channel
122
+ if (member && channel.startsWith('presence-')) {
123
+ let members = presenceData.get(channel);
124
+ if (!members) {
125
+ members = new Map();
126
+ presenceData.set(channel, members);
127
+ }
128
+ members.set(connectionId, member);
129
+ conn.presenceInfo.set(channel, member);
130
+ // Notify others of new member
131
+ broadcastLocal({
132
+ channel,
133
+ event: 'member_added',
134
+ data: member,
135
+ except: connectionId,
136
+ });
137
+ }
138
+ // Send success message
139
+ send(conn, {
140
+ type: 'subscription_succeeded',
141
+ channel,
142
+ data: member && channel.startsWith('presence-')
143
+ ? { members: Array.from(presenceData.get(channel)?.values() ?? []) }
144
+ : undefined,
145
+ });
146
+ }
147
+ /**
148
+ * Unsubscribe a connection from a channel.
149
+ */
150
+ function unsubscribe(connectionId, channel) {
151
+ const conn = connections.get(connectionId);
152
+ if (!conn)
153
+ return;
154
+ // Remove from connection's channels
155
+ conn.channels.delete(channel);
156
+ // Remove from channel subscriptions
157
+ const subscribers = channelSubscriptions.get(channel);
158
+ if (subscribers) {
159
+ subscribers.delete(connectionId);
160
+ if (subscribers.size === 0) {
161
+ channelSubscriptions.delete(channel);
162
+ }
163
+ }
164
+ // Handle presence channel
165
+ if (channel.startsWith('presence-')) {
166
+ const members = presenceData.get(channel);
167
+ if (members) {
168
+ const member = members.get(connectionId);
169
+ members.delete(connectionId);
170
+ conn.presenceInfo.delete(channel);
171
+ if (members.size === 0) {
172
+ presenceData.delete(channel);
173
+ }
174
+ // Notify others of member leaving
175
+ if (member) {
176
+ broadcastLocal({
177
+ channel,
178
+ event: 'member_removed',
179
+ data: member,
180
+ });
181
+ }
182
+ }
183
+ }
184
+ }
185
+ /**
186
+ * Handle connection close.
187
+ */
188
+ function handleDisconnect(connectionId) {
189
+ const conn = connections.get(connectionId);
190
+ if (!conn)
191
+ return;
192
+ // Unsubscribe from all channels
193
+ for (const channel of conn.channels) {
194
+ unsubscribe(connectionId, channel);
195
+ }
196
+ // Remove from connections
197
+ connections.delete(connectionId);
198
+ }
199
+ // Heartbeat interval to keep connections alive
200
+ const heartbeatIntervalId = setInterval(() => {
201
+ for (const conn of connections.values()) {
202
+ sendHeartbeat(conn);
203
+ }
204
+ }, heartbeatInterval);
205
+ /**
206
+ * SSE request handler.
207
+ */
208
+ async function handler(request, reply) {
209
+ const connectionId = generateConnectionId();
210
+ // Set SSE headers
211
+ reply.raw.writeHead(200, {
212
+ 'Content-Type': 'text/event-stream',
213
+ 'Cache-Control': 'no-cache',
214
+ Connection: 'keep-alive',
215
+ 'X-Accel-Buffering': 'no', // Disable nginx buffering
216
+ });
217
+ // Send retry interval
218
+ reply.raw.write(`retry: ${retryInterval}\n\n`);
219
+ // Create connection
220
+ const conn = {
221
+ id: connectionId,
222
+ reply,
223
+ channels: new Set(),
224
+ presenceInfo: new Map(),
225
+ lastActivity: new Date(),
226
+ };
227
+ connections.set(connectionId, conn);
228
+ // Send connection info
229
+ send(conn, {
230
+ type: 'event',
231
+ event: 'connected',
232
+ data: { connectionId },
233
+ });
234
+ // Handle client disconnect
235
+ request.raw.on('close', () => {
236
+ handleDisconnect(connectionId);
237
+ });
238
+ // Keep connection open
239
+ // The reply is not ended here; SSE keeps streaming
240
+ }
241
+ const driver = {
242
+ handler,
243
+ subscribe,
244
+ unsubscribe,
245
+ async broadcast(event) {
246
+ broadcastLocal(event);
247
+ },
248
+ async getSubscribers(channel) {
249
+ const subscribers = channelSubscriptions.get(channel);
250
+ return subscribers ? Array.from(subscribers) : [];
251
+ },
252
+ async getPresenceMembers(channel) {
253
+ const members = presenceData.get(channel);
254
+ return members ? Array.from(members.values()) : [];
255
+ },
256
+ async getConnectionCount(channel) {
257
+ const subscribers = channelSubscriptions.get(channel);
258
+ return subscribers?.size ?? 0;
259
+ },
260
+ async getChannels() {
261
+ return Array.from(channelSubscriptions.keys());
262
+ },
263
+ async close() {
264
+ clearInterval(heartbeatIntervalId);
265
+ // Close all connections
266
+ for (const conn of connections.values()) {
267
+ try {
268
+ conn.reply.raw.end();
269
+ }
270
+ catch {
271
+ // Ignore
272
+ }
273
+ }
274
+ connections.clear();
275
+ channelSubscriptions.clear();
276
+ presenceData.clear();
277
+ },
278
+ };
279
+ return driver;
280
+ }
281
+ /**
282
+ * SSE driver name.
283
+ */
284
+ export const DRIVER_NAME = 'sse';