@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 +199 -0
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/dist/auth.d.ts +51 -0
- package/dist/auth.js +71 -0
- package/dist/drivers/sse.d.ts +42 -0
- package/dist/drivers/sse.js +284 -0
- package/dist/drivers/ws.d.ts +41 -0
- package/dist/drivers/ws.js +401 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +49 -0
- package/dist/manager.d.ts +54 -0
- package/dist/manager.js +104 -0
- package/dist/plugin.d.ts +105 -0
- package/dist/plugin.js +242 -0
- package/dist/schemas.d.ts +178 -0
- package/dist/schemas.js +101 -0
- package/dist/types.d.ts +265 -0
- package/dist/types.js +7 -0
- package/package.json +83 -0
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';
|