@mantiq/realtime 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/package.json +56 -0
- package/src/RealtimeServiceProvider.ts +71 -0
- package/src/broadcast/BunBroadcaster.ts +24 -0
- package/src/channels/ChannelManager.ts +309 -0
- package/src/contracts/Channel.ts +40 -0
- package/src/contracts/RealtimeConfig.ts +72 -0
- package/src/errors/RealtimeError.ts +6 -0
- package/src/helpers/realtime.ts +42 -0
- package/src/index.ts +48 -0
- package/src/protocol/Protocol.ts +138 -0
- package/src/server/ConnectionManager.ts +192 -0
- package/src/server/WebSocketServer.ts +159 -0
- package/src/sse/SSEManager.ts +228 -0
- package/src/testing/RealtimeFake.ts +137 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol for client ↔ server communication.
|
|
3
|
+
*
|
|
4
|
+
* All messages are JSON objects with an `event` field.
|
|
5
|
+
* Channel names encode their type via prefix: "private:", "presence:", or none (public).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Client → Server ─────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface SubscribeMessage {
|
|
11
|
+
event: 'subscribe'
|
|
12
|
+
channel: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UnsubscribeMessage {
|
|
16
|
+
event: 'unsubscribe'
|
|
17
|
+
channel: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WhisperMessage {
|
|
21
|
+
event: 'whisper'
|
|
22
|
+
channel: string
|
|
23
|
+
type: string
|
|
24
|
+
data: Record<string, any>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PingMessage {
|
|
28
|
+
event: 'ping'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ClientMessage =
|
|
32
|
+
| SubscribeMessage
|
|
33
|
+
| UnsubscribeMessage
|
|
34
|
+
| WhisperMessage
|
|
35
|
+
| PingMessage
|
|
36
|
+
|
|
37
|
+
// ── Server → Client ─────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export interface SubscribedMessage {
|
|
40
|
+
event: 'subscribed'
|
|
41
|
+
channel: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UnsubscribedMessage {
|
|
45
|
+
event: 'unsubscribed'
|
|
46
|
+
channel: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ErrorMessage {
|
|
50
|
+
event: 'error'
|
|
51
|
+
message: string
|
|
52
|
+
channel?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PongMessage {
|
|
56
|
+
event: 'pong'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface BroadcastMessage {
|
|
60
|
+
event: string
|
|
61
|
+
channel: string
|
|
62
|
+
data: Record<string, any>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MemberJoinedMessage {
|
|
66
|
+
event: 'member:joined'
|
|
67
|
+
channel: string
|
|
68
|
+
data: { userId: string | number; info: Record<string, any> }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface MemberLeftMessage {
|
|
72
|
+
event: 'member:left'
|
|
73
|
+
channel: string
|
|
74
|
+
data: { userId: string | number }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface MemberHereMessage {
|
|
78
|
+
event: 'member:here'
|
|
79
|
+
channel: string
|
|
80
|
+
data: Array<{ userId: string | number; info: Record<string, any> }>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type ServerMessage =
|
|
84
|
+
| SubscribedMessage
|
|
85
|
+
| UnsubscribedMessage
|
|
86
|
+
| ErrorMessage
|
|
87
|
+
| PongMessage
|
|
88
|
+
| BroadcastMessage
|
|
89
|
+
| MemberJoinedMessage
|
|
90
|
+
| MemberLeftMessage
|
|
91
|
+
| MemberHereMessage
|
|
92
|
+
|
|
93
|
+
// ── Parsing ─────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a raw WebSocket message into a typed client message.
|
|
97
|
+
* Returns null if the message is invalid.
|
|
98
|
+
*/
|
|
99
|
+
export function parseClientMessage(raw: string | Buffer): ClientMessage | null {
|
|
100
|
+
try {
|
|
101
|
+
const str = typeof raw === 'string' ? raw : raw.toString('utf-8')
|
|
102
|
+
const msg = JSON.parse(str)
|
|
103
|
+
|
|
104
|
+
if (typeof msg !== 'object' || msg === null || typeof msg.event !== 'string') {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
switch (msg.event) {
|
|
109
|
+
case 'subscribe':
|
|
110
|
+
if (typeof msg.channel !== 'string' || !msg.channel) return null
|
|
111
|
+
return { event: 'subscribe', channel: msg.channel }
|
|
112
|
+
|
|
113
|
+
case 'unsubscribe':
|
|
114
|
+
if (typeof msg.channel !== 'string' || !msg.channel) return null
|
|
115
|
+
return { event: 'unsubscribe', channel: msg.channel }
|
|
116
|
+
|
|
117
|
+
case 'whisper':
|
|
118
|
+
if (typeof msg.channel !== 'string' || !msg.channel) return null
|
|
119
|
+
if (typeof msg.type !== 'string' || !msg.type) return null
|
|
120
|
+
return { event: 'whisper', channel: msg.channel, type: msg.type, data: msg.data ?? {} }
|
|
121
|
+
|
|
122
|
+
case 'ping':
|
|
123
|
+
return { event: 'ping' }
|
|
124
|
+
|
|
125
|
+
default:
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Serialize a server message to a JSON string.
|
|
135
|
+
*/
|
|
136
|
+
export function serialize(msg: ServerMessage): string {
|
|
137
|
+
return JSON.stringify(msg)
|
|
138
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { WebSocketContext } from '@mantiq/core'
|
|
2
|
+
import type { RealtimeConfig } from '../contracts/RealtimeConfig.ts'
|
|
3
|
+
import { RealtimeError } from '../errors/RealtimeError.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bun's ServerWebSocket with our context attached.
|
|
7
|
+
*/
|
|
8
|
+
export interface RealtimeSocket {
|
|
9
|
+
readonly data: WebSocketContext
|
|
10
|
+
send(data: string | ArrayBuffer | Uint8Array, compress?: boolean): number
|
|
11
|
+
close(code?: number, reason?: string): void
|
|
12
|
+
subscribe(topic: string): void
|
|
13
|
+
unsubscribe(topic: string): void
|
|
14
|
+
publish(topic: string, data: string | ArrayBuffer | Uint8Array, compress?: boolean): number
|
|
15
|
+
isSubscribed(topic: string): boolean
|
|
16
|
+
readonly readyState: number
|
|
17
|
+
readonly remoteAddress: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Tracks all active WebSocket connections.
|
|
22
|
+
*
|
|
23
|
+
* Handles per-user connection limits, heartbeat ping/pong,
|
|
24
|
+
* and provides lookup by userId or connection ID.
|
|
25
|
+
*/
|
|
26
|
+
export class ConnectionManager {
|
|
27
|
+
/** All active connections indexed by a unique connection ID. */
|
|
28
|
+
private connections = new Map<string, RealtimeSocket>()
|
|
29
|
+
|
|
30
|
+
/** User ID → set of connection IDs. */
|
|
31
|
+
private userConnections = new Map<string | number, Set<string>>()
|
|
32
|
+
|
|
33
|
+
/** Connection ID → last pong timestamp. */
|
|
34
|
+
private lastPong = new Map<string, number>()
|
|
35
|
+
|
|
36
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
37
|
+
private connectionCounter = 0
|
|
38
|
+
|
|
39
|
+
constructor(private config: RealtimeConfig) {}
|
|
40
|
+
|
|
41
|
+
// ── Connection Lifecycle ────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register a new connection. Returns a unique connection ID.
|
|
45
|
+
* Throws if connection limits are exceeded.
|
|
46
|
+
*/
|
|
47
|
+
add(ws: RealtimeSocket): string {
|
|
48
|
+
const userId = ws.data.userId
|
|
49
|
+
const maxTotal = this.config.websocket.maxConnections
|
|
50
|
+
const maxPerUser = this.config.websocket.maxConnectionsPerUser
|
|
51
|
+
|
|
52
|
+
// Check total connection limit
|
|
53
|
+
if (maxTotal > 0 && this.connections.size >= maxTotal) {
|
|
54
|
+
throw new RealtimeError('Max connections exceeded', { maxConnections: maxTotal })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check per-user limit
|
|
58
|
+
if (userId !== undefined && maxPerUser > 0) {
|
|
59
|
+
const userConns = this.userConnections.get(userId)
|
|
60
|
+
if (userConns && userConns.size >= maxPerUser) {
|
|
61
|
+
throw new RealtimeError('Max connections per user exceeded', { userId, maxPerUser })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const connId = `conn_${++this.connectionCounter}_${Date.now()}`
|
|
66
|
+
this.connections.set(connId, ws)
|
|
67
|
+
this.lastPong.set(connId, Date.now())
|
|
68
|
+
|
|
69
|
+
// Store in context metadata for reverse lookup
|
|
70
|
+
ws.data.metadata._connId = connId
|
|
71
|
+
|
|
72
|
+
// Track user connections
|
|
73
|
+
if (userId !== undefined) {
|
|
74
|
+
if (!this.userConnections.has(userId)) {
|
|
75
|
+
this.userConnections.set(userId, new Set())
|
|
76
|
+
}
|
|
77
|
+
this.userConnections.get(userId)!.add(connId)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return connId
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Remove a connection.
|
|
85
|
+
*/
|
|
86
|
+
remove(ws: RealtimeSocket): void {
|
|
87
|
+
const connId = ws.data.metadata._connId as string | undefined
|
|
88
|
+
if (!connId) return
|
|
89
|
+
|
|
90
|
+
this.connections.delete(connId)
|
|
91
|
+
this.lastPong.delete(connId)
|
|
92
|
+
|
|
93
|
+
const userId = ws.data.userId
|
|
94
|
+
if (userId !== undefined) {
|
|
95
|
+
const userConns = this.userConnections.get(userId)
|
|
96
|
+
if (userConns) {
|
|
97
|
+
userConns.delete(connId)
|
|
98
|
+
if (userConns.size === 0) this.userConnections.delete(userId)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Record a pong from a connection.
|
|
105
|
+
*/
|
|
106
|
+
recordPong(ws: RealtimeSocket): void {
|
|
107
|
+
const connId = ws.data.metadata._connId as string | undefined
|
|
108
|
+
if (connId) this.lastPong.set(connId, Date.now())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Lookup ──────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
get(connId: string): RealtimeSocket | undefined {
|
|
114
|
+
return this.connections.get(connId)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getByUser(userId: string | number): RealtimeSocket[] {
|
|
118
|
+
const connIds = this.userConnections.get(userId)
|
|
119
|
+
if (!connIds) return []
|
|
120
|
+
return [...connIds].map((id) => this.connections.get(id)!).filter(Boolean)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getAll(): RealtimeSocket[] {
|
|
124
|
+
return [...this.connections.values()]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
count(): number {
|
|
128
|
+
return this.connections.size
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
userCount(): number {
|
|
132
|
+
return this.userConnections.size
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Heartbeat ───────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Start sending periodic pings. Connections that don't pong are closed.
|
|
139
|
+
*/
|
|
140
|
+
startHeartbeat(): void {
|
|
141
|
+
if (this.heartbeatTimer) return
|
|
142
|
+
|
|
143
|
+
const interval = this.config.websocket.heartbeatInterval
|
|
144
|
+
const timeout = this.config.websocket.heartbeatTimeout
|
|
145
|
+
|
|
146
|
+
this.heartbeatTimer = setInterval(() => {
|
|
147
|
+
const now = Date.now()
|
|
148
|
+
const stale: RealtimeSocket[] = []
|
|
149
|
+
|
|
150
|
+
for (const [connId, ws] of this.connections) {
|
|
151
|
+
const lastPong = this.lastPong.get(connId) ?? 0
|
|
152
|
+
if (now - lastPong > interval + timeout) {
|
|
153
|
+
stale.push(ws)
|
|
154
|
+
} else {
|
|
155
|
+
// Send ping
|
|
156
|
+
try {
|
|
157
|
+
ws.send(JSON.stringify({ event: 'ping' }))
|
|
158
|
+
} catch {
|
|
159
|
+
stale.push(ws)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Close stale connections
|
|
165
|
+
for (const ws of stale) {
|
|
166
|
+
try { ws.close(4000, 'Heartbeat timeout') } catch { /* already closed */ }
|
|
167
|
+
}
|
|
168
|
+
}, interval)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Stop the heartbeat timer.
|
|
173
|
+
*/
|
|
174
|
+
stopHeartbeat(): void {
|
|
175
|
+
if (this.heartbeatTimer) {
|
|
176
|
+
clearInterval(this.heartbeatTimer)
|
|
177
|
+
this.heartbeatTimer = null
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
shutdown(): void {
|
|
184
|
+
this.stopHeartbeat()
|
|
185
|
+
for (const ws of this.connections.values()) {
|
|
186
|
+
try { ws.close(1001, 'Server shutting down') } catch { /* ignore */ }
|
|
187
|
+
}
|
|
188
|
+
this.connections.clear()
|
|
189
|
+
this.userConnections.clear()
|
|
190
|
+
this.lastPong.clear()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { MantiqRequest } from '@mantiq/core'
|
|
2
|
+
import type { WebSocketContext, WebSocketHandler } from '@mantiq/core'
|
|
3
|
+
import type { RealtimeConfig } from '../contracts/RealtimeConfig.ts'
|
|
4
|
+
import type { RealtimeSocket } from './ConnectionManager.ts'
|
|
5
|
+
import { ConnectionManager } from './ConnectionManager.ts'
|
|
6
|
+
import { ChannelManager } from '../channels/ChannelManager.ts'
|
|
7
|
+
import { parseClientMessage } from '../protocol/Protocol.ts'
|
|
8
|
+
import { serialize } from '../protocol/Protocol.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The core WebSocket handler for @mantiq/realtime.
|
|
12
|
+
*
|
|
13
|
+
* Implements `WebSocketHandler` from @mantiq/core and orchestrates
|
|
14
|
+
* connection management, channel subscriptions, and message routing.
|
|
15
|
+
*
|
|
16
|
+
* Registered with `WebSocketKernel.registerHandler()` during boot.
|
|
17
|
+
*/
|
|
18
|
+
export class WebSocketServer implements WebSocketHandler {
|
|
19
|
+
readonly connections: ConnectionManager
|
|
20
|
+
readonly channels: ChannelManager
|
|
21
|
+
|
|
22
|
+
/** User-provided authentication callback. */
|
|
23
|
+
private authenticator: ((request: MantiqRequest) => Promise<{ userId?: string | number; metadata?: Record<string, any> } | null>) | null = null
|
|
24
|
+
|
|
25
|
+
constructor(private config: RealtimeConfig) {
|
|
26
|
+
this.connections = new ConnectionManager(config)
|
|
27
|
+
this.channels = new ChannelManager(config)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Configuration ──────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Register an authentication callback for WebSocket connections.
|
|
34
|
+
*
|
|
35
|
+
* Called during upgrade to determine the user identity.
|
|
36
|
+
* Return `null` to reject the connection, or an object with userId/metadata.
|
|
37
|
+
*
|
|
38
|
+
* ```typescript
|
|
39
|
+
* realtime.authenticate(async (request) => {
|
|
40
|
+
* const token = request.header('authorization')?.replace('Bearer ', '')
|
|
41
|
+
* const user = await verifyToken(token)
|
|
42
|
+
* return user ? { userId: user.id, metadata: { name: user.name } } : null
|
|
43
|
+
* })
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
authenticate(callback: (request: MantiqRequest) => Promise<{ userId?: string | number; metadata?: Record<string, any> } | null>): void {
|
|
47
|
+
this.authenticator = callback
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── WebSocketHandler Implementation ────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Called by WebSocketKernel before upgrade.
|
|
54
|
+
* Authenticates the request and returns the WebSocket context.
|
|
55
|
+
*/
|
|
56
|
+
async onUpgrade(request: MantiqRequest): Promise<WebSocketContext | null> {
|
|
57
|
+
// Check if the request is targeting our WebSocket path
|
|
58
|
+
const requestPath = request.path()
|
|
59
|
+
if (requestPath !== this.config.websocket.path) {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let userId: string | number | undefined
|
|
64
|
+
let metadata: Record<string, any> = {}
|
|
65
|
+
|
|
66
|
+
if (this.authenticator) {
|
|
67
|
+
const result = await this.authenticator(request)
|
|
68
|
+
if (result === null) {
|
|
69
|
+
return null // Auth rejected
|
|
70
|
+
}
|
|
71
|
+
userId = result.userId
|
|
72
|
+
metadata = result.metadata ?? {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
userId,
|
|
77
|
+
channels: new Set<string>(),
|
|
78
|
+
metadata,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Called when a WebSocket connection is established.
|
|
84
|
+
*/
|
|
85
|
+
open(ws: RealtimeSocket): void {
|
|
86
|
+
try {
|
|
87
|
+
const connId = this.connections.add(ws)
|
|
88
|
+
ws.send(serialize({
|
|
89
|
+
event: 'connected',
|
|
90
|
+
channel: '',
|
|
91
|
+
data: { connectionId: connId },
|
|
92
|
+
} as any))
|
|
93
|
+
} catch (error: any) {
|
|
94
|
+
ws.send(serialize({ event: 'error', message: error.message }))
|
|
95
|
+
ws.close(4002, error.message)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Called when a message is received from a client.
|
|
101
|
+
*/
|
|
102
|
+
async message(ws: RealtimeSocket, raw: string | Buffer): Promise<void> {
|
|
103
|
+
const msg = parseClientMessage(raw)
|
|
104
|
+
if (!msg) {
|
|
105
|
+
ws.send(serialize({ event: 'error', message: 'Invalid message format' }))
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
switch (msg.event) {
|
|
110
|
+
case 'subscribe':
|
|
111
|
+
await this.channels.subscribe(ws, msg.channel)
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
case 'unsubscribe':
|
|
115
|
+
this.channels.unsubscribe(ws, msg.channel)
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
case 'whisper':
|
|
119
|
+
this.channels.whisper(ws, msg.channel, msg.type, msg.data)
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
case 'ping':
|
|
123
|
+
this.connections.recordPong(ws)
|
|
124
|
+
ws.send(serialize({ event: 'pong' } as any))
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Called when a WebSocket connection is closed.
|
|
131
|
+
*/
|
|
132
|
+
close(ws: RealtimeSocket, _code: number, _reason: string): void {
|
|
133
|
+
this.channels.removeFromAll(ws)
|
|
134
|
+
this.connections.remove(ws)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Called when the WebSocket backpressure drains.
|
|
139
|
+
*/
|
|
140
|
+
drain(_ws: RealtimeSocket): void {
|
|
141
|
+
// No-op — Bun handles backpressure automatically
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Server Lifecycle ───────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Start the heartbeat monitor for stale connections.
|
|
148
|
+
*/
|
|
149
|
+
start(): void {
|
|
150
|
+
this.connections.startHeartbeat()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Gracefully shut down: close all connections, stop heartbeat.
|
|
155
|
+
*/
|
|
156
|
+
shutdown(): void {
|
|
157
|
+
this.connections.shutdown()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { RealtimeConfig } from '../contracts/RealtimeConfig.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Represents an active SSE connection.
|
|
5
|
+
*/
|
|
6
|
+
export interface SSEConnection {
|
|
7
|
+
id: string
|
|
8
|
+
userId?: string | number
|
|
9
|
+
channels: Set<string>
|
|
10
|
+
controller: ReadableStreamDefaultController
|
|
11
|
+
lastEventId: number
|
|
12
|
+
keepAliveTimer: ReturnType<typeof setInterval> | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Manages Server-Sent Events connections as a fallback transport.
|
|
17
|
+
*
|
|
18
|
+
* SSE is unidirectional (server → client). Clients subscribe to channels
|
|
19
|
+
* via query params or a separate POST endpoint. The server pushes events
|
|
20
|
+
* via the SSE stream.
|
|
21
|
+
*
|
|
22
|
+
* This is a simpler alternative to WebSockets for environments that
|
|
23
|
+
* don't support them (firewalls, proxies, etc.).
|
|
24
|
+
*/
|
|
25
|
+
export class SSEManager {
|
|
26
|
+
/** All active SSE connections. */
|
|
27
|
+
private connections = new Map<string, SSEConnection>()
|
|
28
|
+
|
|
29
|
+
/** Channel → set of connection IDs. */
|
|
30
|
+
private channelSubscriptions = new Map<string, Set<string>>()
|
|
31
|
+
|
|
32
|
+
private connectionCounter = 0
|
|
33
|
+
|
|
34
|
+
constructor(private config: RealtimeConfig) {}
|
|
35
|
+
|
|
36
|
+
// ── Connection Lifecycle ──────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create an SSE response for a new client connection.
|
|
40
|
+
*
|
|
41
|
+
* Usage in a route handler:
|
|
42
|
+
* ```typescript
|
|
43
|
+
* router.get('/_sse', (request) => {
|
|
44
|
+
* return sseManager.connect(request)
|
|
45
|
+
* })
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
connect(options: {
|
|
49
|
+
userId?: string | number
|
|
50
|
+
channels?: string[]
|
|
51
|
+
lastEventId?: string
|
|
52
|
+
} = {}): Response {
|
|
53
|
+
const connId = `sse_${++this.connectionCounter}_${Date.now()}`
|
|
54
|
+
|
|
55
|
+
let connection: SSEConnection
|
|
56
|
+
|
|
57
|
+
const stream = new ReadableStream({
|
|
58
|
+
start: (controller) => {
|
|
59
|
+
connection = {
|
|
60
|
+
id: connId,
|
|
61
|
+
userId: options.userId,
|
|
62
|
+
channels: new Set(),
|
|
63
|
+
controller,
|
|
64
|
+
lastEventId: 0,
|
|
65
|
+
keepAliveTimer: null,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.connections.set(connId, connection)
|
|
69
|
+
|
|
70
|
+
// Send initial connection event
|
|
71
|
+
this.sendEvent(connection, 'connected', { connectionId: connId })
|
|
72
|
+
|
|
73
|
+
// Subscribe to requested channels
|
|
74
|
+
if (options.channels) {
|
|
75
|
+
for (const channel of options.channels) {
|
|
76
|
+
this.subscribe(connId, channel)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Start keep-alive
|
|
81
|
+
connection.keepAliveTimer = setInterval(() => {
|
|
82
|
+
try {
|
|
83
|
+
controller.enqueue(': keep-alive\n\n')
|
|
84
|
+
} catch {
|
|
85
|
+
this.disconnect(connId)
|
|
86
|
+
}
|
|
87
|
+
}, this.config.sse.keepAliveInterval)
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
cancel: () => {
|
|
91
|
+
this.disconnect(connId)
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
return new Response(stream, {
|
|
96
|
+
headers: {
|
|
97
|
+
'Content-Type': 'text/event-stream',
|
|
98
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
99
|
+
'Connection': 'keep-alive',
|
|
100
|
+
'X-Accel-Buffering': 'no',
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Disconnect an SSE client.
|
|
107
|
+
*/
|
|
108
|
+
disconnect(connId: string): void {
|
|
109
|
+
const conn = this.connections.get(connId)
|
|
110
|
+
if (!conn) return
|
|
111
|
+
|
|
112
|
+
// Clear keep-alive
|
|
113
|
+
if (conn.keepAliveTimer) {
|
|
114
|
+
clearInterval(conn.keepAliveTimer)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Remove from all channel subscriptions
|
|
118
|
+
for (const channel of conn.channels) {
|
|
119
|
+
const subs = this.channelSubscriptions.get(channel)
|
|
120
|
+
if (subs) {
|
|
121
|
+
subs.delete(connId)
|
|
122
|
+
if (subs.size === 0) {
|
|
123
|
+
this.channelSubscriptions.delete(channel)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Close the stream
|
|
129
|
+
try {
|
|
130
|
+
conn.controller.close()
|
|
131
|
+
} catch { /* already closed */ }
|
|
132
|
+
|
|
133
|
+
this.connections.delete(connId)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Channel Subscriptions ─────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Subscribe an SSE connection to a channel.
|
|
140
|
+
* Note: SSE only supports public channels (no auth handshake).
|
|
141
|
+
*/
|
|
142
|
+
subscribe(connId: string, channel: string): boolean {
|
|
143
|
+
const conn = this.connections.get(connId)
|
|
144
|
+
if (!conn) return false
|
|
145
|
+
|
|
146
|
+
conn.channels.add(channel)
|
|
147
|
+
|
|
148
|
+
if (!this.channelSubscriptions.has(channel)) {
|
|
149
|
+
this.channelSubscriptions.set(channel, new Set())
|
|
150
|
+
}
|
|
151
|
+
this.channelSubscriptions.get(channel)!.add(connId)
|
|
152
|
+
|
|
153
|
+
this.sendEvent(conn, 'subscribed', { channel })
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Unsubscribe an SSE connection from a channel.
|
|
159
|
+
*/
|
|
160
|
+
unsubscribe(connId: string, channel: string): void {
|
|
161
|
+
const conn = this.connections.get(connId)
|
|
162
|
+
if (!conn) return
|
|
163
|
+
|
|
164
|
+
conn.channels.delete(channel)
|
|
165
|
+
|
|
166
|
+
const subs = this.channelSubscriptions.get(channel)
|
|
167
|
+
if (subs) {
|
|
168
|
+
subs.delete(connId)
|
|
169
|
+
if (subs.size === 0) {
|
|
170
|
+
this.channelSubscriptions.delete(channel)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Broadcasting ──────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Broadcast an event to all SSE connections subscribed to a channel.
|
|
179
|
+
*/
|
|
180
|
+
broadcast(channel: string, event: string, data: Record<string, any>): void {
|
|
181
|
+
const subs = this.channelSubscriptions.get(channel)
|
|
182
|
+
if (!subs || subs.size === 0) return
|
|
183
|
+
|
|
184
|
+
for (const connId of subs) {
|
|
185
|
+
const conn = this.connections.get(connId)
|
|
186
|
+
if (conn) {
|
|
187
|
+
this.sendEvent(conn, event, { channel, data })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Query ─────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
count(): number {
|
|
195
|
+
return this.connections.size
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
getChannels(): string[] {
|
|
199
|
+
return [...this.channelSubscriptions.keys()]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Private ───────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
private sendEvent(conn: SSEConnection, event: string, data: any): void {
|
|
205
|
+
conn.lastEventId++
|
|
206
|
+
const payload = [
|
|
207
|
+
`id: ${conn.lastEventId}`,
|
|
208
|
+
`event: ${event}`,
|
|
209
|
+
`data: ${JSON.stringify(data)}`,
|
|
210
|
+
'',
|
|
211
|
+
'',
|
|
212
|
+
].join('\n')
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
conn.controller.enqueue(payload)
|
|
216
|
+
} catch {
|
|
217
|
+
this.disconnect(conn.id)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
shutdown(): void {
|
|
224
|
+
for (const connId of [...this.connections.keys()]) {
|
|
225
|
+
this.disconnect(connId)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|