@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.
@@ -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
+ }