@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
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @mantiq/realtime
|
|
2
|
+
|
|
3
|
+
WebSocket server, SSE, and channel system for MantiqJS — public, private, and presence channels.
|
|
4
|
+
|
|
5
|
+
Part of [MantiqJS](https://github.com/abdullahkhan/mantiq) — a batteries-included TypeScript web framework for Bun.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @mantiq/realtime
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
See the [MantiqJS repository](https://github.com/abdullahkhan/mantiq) for full documentation.
|
|
16
|
+
|
|
17
|
+
## License
|
|
18
|
+
|
|
19
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mantiq/realtime",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "WebSocket, SSE, channels, broadcasting",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Abdullah Khan",
|
|
8
|
+
"homepage": "https://github.com/abdullahkhan/mantiq/tree/main/packages/realtime",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/abdullahkhan/mantiq.git",
|
|
12
|
+
"directory": "packages/realtime"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/abdullahkhan/mantiq/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mantiq",
|
|
19
|
+
"mantiqjs",
|
|
20
|
+
"bun",
|
|
21
|
+
"typescript",
|
|
22
|
+
"framework",
|
|
23
|
+
"realtime"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.1.0"
|
|
27
|
+
},
|
|
28
|
+
"main": "./src/index.ts",
|
|
29
|
+
"types": "./src/index.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"bun": "./src/index.ts",
|
|
33
|
+
"default": "./src/index.ts"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src/",
|
|
38
|
+
"package.json",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
44
|
+
"test": "bun test",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"clean": "rm -rf dist"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@mantiq/core": "workspace:*",
|
|
50
|
+
"@mantiq/events": "workspace:*"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"bun-types": "latest",
|
|
54
|
+
"typescript": "^5.7.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ServiceProvider, ConfigRepository, WebSocketKernel } from '@mantiq/core'
|
|
2
|
+
import { BroadcastManager } from '@mantiq/events'
|
|
3
|
+
import { WebSocketServer } from './server/WebSocketServer.ts'
|
|
4
|
+
import { BunBroadcaster } from './broadcast/BunBroadcaster.ts'
|
|
5
|
+
import { SSEManager } from './sse/SSEManager.ts'
|
|
6
|
+
import type { RealtimeConfig } from './contracts/RealtimeConfig.ts'
|
|
7
|
+
import { DEFAULT_CONFIG } from './contracts/RealtimeConfig.ts'
|
|
8
|
+
import { REALTIME, setRealtimeInstance } from './helpers/realtime.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Wires up the realtime server, broadcast driver, and SSE fallback.
|
|
12
|
+
*
|
|
13
|
+
* Register this provider in your application to enable WebSocket support:
|
|
14
|
+
*
|
|
15
|
+
* ```typescript
|
|
16
|
+
* // app.ts
|
|
17
|
+
* app.register(RealtimeServiceProvider)
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Then define channel authorization in your boot code:
|
|
21
|
+
*
|
|
22
|
+
* ```typescript
|
|
23
|
+
* import { realtime } from '@mantiq/realtime'
|
|
24
|
+
*
|
|
25
|
+
* realtime().channels.authorize('orders.*', async (userId, channel) => {
|
|
26
|
+
* const orderId = channel.split('.')[1]
|
|
27
|
+
* return await userOwnsOrder(userId, orderId)
|
|
28
|
+
* })
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export class RealtimeServiceProvider extends ServiceProvider {
|
|
32
|
+
override register(): void {
|
|
33
|
+
// Merge user config with defaults
|
|
34
|
+
let config = DEFAULT_CONFIG
|
|
35
|
+
try {
|
|
36
|
+
const configRepo = this.app.make(ConfigRepository)
|
|
37
|
+
const userConfig = configRepo.get<Partial<RealtimeConfig>>('realtime', {})
|
|
38
|
+
config = { ...DEFAULT_CONFIG, ...userConfig }
|
|
39
|
+
} catch {
|
|
40
|
+
// ConfigRepository not yet registered — use defaults
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// WebSocket server — singleton
|
|
44
|
+
this.app.singleton(WebSocketServer, () => new WebSocketServer(config))
|
|
45
|
+
this.app.alias(WebSocketServer, REALTIME)
|
|
46
|
+
|
|
47
|
+
// SSE manager — singleton
|
|
48
|
+
this.app.singleton(SSEManager, () => new SSEManager(config))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override boot(): void {
|
|
52
|
+
const server = this.app.make(WebSocketServer)
|
|
53
|
+
setRealtimeInstance(server)
|
|
54
|
+
|
|
55
|
+
// Register with the WebSocket kernel so HttpKernel can route upgrades
|
|
56
|
+
const wsKernel = this.app.make(WebSocketKernel)
|
|
57
|
+
wsKernel.registerHandler(server)
|
|
58
|
+
|
|
59
|
+
// Register the 'bun' broadcast driver with BroadcastManager
|
|
60
|
+
try {
|
|
61
|
+
const broadcastManager = this.app.make(BroadcastManager)
|
|
62
|
+
broadcastManager.extend('bun', () => new BunBroadcaster(server.channels))
|
|
63
|
+
} catch {
|
|
64
|
+
// @mantiq/events not installed — broadcasting via events won't work,
|
|
65
|
+
// but direct channel.broadcast() still works
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Start heartbeat monitor
|
|
69
|
+
server.start()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Broadcaster } from '@mantiq/events'
|
|
2
|
+
import type { ChannelManager } from '../channels/ChannelManager.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Broadcasts events to WebSocket subscribers via Bun's in-process pub/sub.
|
|
6
|
+
*
|
|
7
|
+
* This is the default broadcast driver for @mantiq/realtime.
|
|
8
|
+
* It works for single-server deployments. For multi-server setups,
|
|
9
|
+
* use the Redis driver instead.
|
|
10
|
+
*
|
|
11
|
+
* Registered with `BroadcastManager.extend('bun', ...)` during boot.
|
|
12
|
+
*/
|
|
13
|
+
export class BunBroadcaster implements Broadcaster {
|
|
14
|
+
constructor(private channelManager: ChannelManager) {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Broadcast an event to all subscribers on the given channels.
|
|
18
|
+
*/
|
|
19
|
+
async broadcast(channels: string[], event: string, data: Record<string, any>): Promise<void> {
|
|
20
|
+
for (const channel of channels) {
|
|
21
|
+
this.channelManager.broadcast(channel, event, data)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import type { ChannelAuthorizer, PresenceMember } from '../contracts/Channel.ts'
|
|
2
|
+
import { parseChannelName } from '../contracts/Channel.ts'
|
|
3
|
+
import type { RealtimeSocket } from '../server/ConnectionManager.ts'
|
|
4
|
+
import type { RealtimeConfig } from '../contracts/RealtimeConfig.ts'
|
|
5
|
+
import { serialize } from '../protocol/Protocol.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Manages channel subscriptions, authorization, and presence tracking.
|
|
9
|
+
*
|
|
10
|
+
* Channels are created lazily on first subscribe and cleaned up when empty.
|
|
11
|
+
* Authorization callbacks are registered by the app in `routes/channels.ts`
|
|
12
|
+
* (or wherever the user configures them).
|
|
13
|
+
*/
|
|
14
|
+
export class ChannelManager {
|
|
15
|
+
/** channel name → set of subscribed sockets. */
|
|
16
|
+
private subscriptions = new Map<string, Set<RealtimeSocket>>()
|
|
17
|
+
|
|
18
|
+
/** Authorization callbacks keyed by channel pattern. */
|
|
19
|
+
private authorizers = new Map<string, ChannelAuthorizer>()
|
|
20
|
+
|
|
21
|
+
/** Presence members keyed by channel name → userId → member info. */
|
|
22
|
+
private presenceMembers = new Map<string, Map<string | number, PresenceMember>>()
|
|
23
|
+
|
|
24
|
+
constructor(private config: RealtimeConfig) {}
|
|
25
|
+
|
|
26
|
+
// ── Authorization Registration ──────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Register an authorization callback for a channel pattern.
|
|
30
|
+
*
|
|
31
|
+
* Patterns support `*` wildcards:
|
|
32
|
+
* - `"orders.*"` matches `"private:orders.1"`, `"private:orders.42"`
|
|
33
|
+
* - `"chat.*"` matches `"presence:chat.room1"`
|
|
34
|
+
*
|
|
35
|
+
* The pattern is matched against the base name (without the prefix).
|
|
36
|
+
*/
|
|
37
|
+
authorize(pattern: string, callback: ChannelAuthorizer): void {
|
|
38
|
+
this.authorizers.set(pattern, callback)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Subscribe / Unsubscribe ─────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Subscribe a socket to a channel.
|
|
45
|
+
* Returns true if subscribed, false if auth denied.
|
|
46
|
+
*/
|
|
47
|
+
async subscribe(ws: RealtimeSocket, channel: string): Promise<boolean> {
|
|
48
|
+
const { type, baseName } = parseChannelName(channel)
|
|
49
|
+
const userId = ws.data.userId
|
|
50
|
+
|
|
51
|
+
// Private and presence channels require authorization
|
|
52
|
+
if (type !== 'public') {
|
|
53
|
+
if (userId === undefined) {
|
|
54
|
+
ws.send(serialize({ event: 'error', message: 'Authentication required', channel }))
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const authorizer = this.findAuthorizer(baseName)
|
|
59
|
+
if (!authorizer) {
|
|
60
|
+
ws.send(serialize({ event: 'error', message: 'No authorization handler for this channel', channel }))
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const result = await authorizer(userId, channel, ws.data.metadata)
|
|
65
|
+
|
|
66
|
+
if (result === false) {
|
|
67
|
+
ws.send(serialize({ event: 'error', message: 'Unauthorized', channel }))
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Presence channels: result can be member info object
|
|
72
|
+
if (type === 'presence') {
|
|
73
|
+
const memberInfo = typeof result === 'object' ? result : {}
|
|
74
|
+
this.addPresenceMember(channel, userId, memberInfo, ws)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Add to subscription set
|
|
79
|
+
if (!this.subscriptions.has(channel)) {
|
|
80
|
+
this.subscriptions.set(channel, new Set())
|
|
81
|
+
}
|
|
82
|
+
this.subscriptions.get(channel)!.add(ws)
|
|
83
|
+
|
|
84
|
+
// Track in the socket's context
|
|
85
|
+
ws.data.channels.add(channel)
|
|
86
|
+
|
|
87
|
+
// Subscribe to Bun's pub/sub topic for this channel
|
|
88
|
+
ws.subscribe(channel)
|
|
89
|
+
|
|
90
|
+
// Confirm subscription
|
|
91
|
+
ws.send(serialize({ event: 'subscribed', channel }))
|
|
92
|
+
|
|
93
|
+
// For presence: send current members list
|
|
94
|
+
if (type === 'presence') {
|
|
95
|
+
const members = this.getPresenceMembers(channel)
|
|
96
|
+
ws.send(serialize({
|
|
97
|
+
event: 'member:here',
|
|
98
|
+
channel,
|
|
99
|
+
data: members.map((m) => ({ userId: m.userId, info: m.info })),
|
|
100
|
+
}))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Unsubscribe a socket from a channel.
|
|
108
|
+
*/
|
|
109
|
+
unsubscribe(ws: RealtimeSocket, channel: string): void {
|
|
110
|
+
const subs = this.subscriptions.get(channel)
|
|
111
|
+
if (subs) {
|
|
112
|
+
subs.delete(ws)
|
|
113
|
+
if (subs.size === 0) {
|
|
114
|
+
this.subscriptions.delete(channel)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
ws.data.channels.delete(channel)
|
|
119
|
+
ws.unsubscribe(channel)
|
|
120
|
+
|
|
121
|
+
// Handle presence leave
|
|
122
|
+
const { type } = parseChannelName(channel)
|
|
123
|
+
if (type === 'presence' && ws.data.userId !== undefined) {
|
|
124
|
+
this.removePresenceMember(channel, ws.data.userId, ws)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
ws.send(serialize({ event: 'unsubscribed', channel }))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Remove a socket from all channels (called on disconnect).
|
|
132
|
+
*/
|
|
133
|
+
removeFromAll(ws: RealtimeSocket): void {
|
|
134
|
+
for (const channel of [...ws.data.channels]) {
|
|
135
|
+
const subs = this.subscriptions.get(channel)
|
|
136
|
+
if (subs) {
|
|
137
|
+
subs.delete(ws)
|
|
138
|
+
if (subs.size === 0) {
|
|
139
|
+
this.subscriptions.delete(channel)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Handle presence leave
|
|
144
|
+
const { type } = parseChannelName(channel)
|
|
145
|
+
if (type === 'presence' && ws.data.userId !== undefined) {
|
|
146
|
+
this.removePresenceMember(channel, ws.data.userId, ws)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
ws.data.channels.clear()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Whisper (client-to-client) ──────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Forward a whisper to all subscribers of a channel except the sender.
|
|
156
|
+
* Only allowed on private and presence channels.
|
|
157
|
+
*/
|
|
158
|
+
whisper(ws: RealtimeSocket, channel: string, type: string, data: Record<string, any>): void {
|
|
159
|
+
const { type: channelType } = parseChannelName(channel)
|
|
160
|
+
if (channelType === 'public') {
|
|
161
|
+
ws.send(serialize({ event: 'error', message: 'Whisper not allowed on public channels', channel }))
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!ws.data.channels.has(channel)) {
|
|
166
|
+
ws.send(serialize({ event: 'error', message: 'Not subscribed to channel', channel }))
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Publish to all subscribers via Bun pub/sub (sender excluded automatically by Bun)
|
|
171
|
+
ws.publish(channel, JSON.stringify({
|
|
172
|
+
event: `client:${type}`,
|
|
173
|
+
channel,
|
|
174
|
+
data,
|
|
175
|
+
}))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Broadcast (server → clients) ───────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Broadcast an event to all subscribers of a channel.
|
|
182
|
+
* Called by the BunBroadcaster when a ShouldBroadcast event is dispatched.
|
|
183
|
+
*/
|
|
184
|
+
broadcast(channel: string, event: string, data: Record<string, any>): void {
|
|
185
|
+
const subs = this.subscriptions.get(channel)
|
|
186
|
+
if (!subs || subs.size === 0) return
|
|
187
|
+
|
|
188
|
+
const message = serialize({ event, channel, data })
|
|
189
|
+
for (const ws of subs) {
|
|
190
|
+
try {
|
|
191
|
+
ws.send(message)
|
|
192
|
+
} catch {
|
|
193
|
+
// Connection may have closed
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Presence ────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
private addPresenceMember(
|
|
201
|
+
channel: string,
|
|
202
|
+
userId: string | number,
|
|
203
|
+
info: Record<string, any>,
|
|
204
|
+
ws: RealtimeSocket,
|
|
205
|
+
): void {
|
|
206
|
+
if (!this.presenceMembers.has(channel)) {
|
|
207
|
+
this.presenceMembers.set(channel, new Map())
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const members = this.presenceMembers.get(channel)!
|
|
211
|
+
const isNew = !members.has(userId)
|
|
212
|
+
|
|
213
|
+
members.set(userId, {
|
|
214
|
+
userId,
|
|
215
|
+
info,
|
|
216
|
+
joinedAt: Date.now(),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// Notify other subscribers about the new member
|
|
220
|
+
if (isNew) {
|
|
221
|
+
const subs = this.subscriptions.get(channel)
|
|
222
|
+
if (subs) {
|
|
223
|
+
const msg = serialize({ event: 'member:joined', channel, data: { userId, info } })
|
|
224
|
+
for (const sub of subs) {
|
|
225
|
+
if (sub !== ws) {
|
|
226
|
+
try { sub.send(msg) } catch { /* ignore */ }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private removePresenceMember(
|
|
234
|
+
channel: string,
|
|
235
|
+
userId: string | number,
|
|
236
|
+
_ws: RealtimeSocket,
|
|
237
|
+
): void {
|
|
238
|
+
const members = this.presenceMembers.get(channel)
|
|
239
|
+
if (!members) return
|
|
240
|
+
|
|
241
|
+
// Only remove if the user has no other connections to this channel
|
|
242
|
+
const subs = this.subscriptions.get(channel)
|
|
243
|
+
if (subs) {
|
|
244
|
+
for (const sub of subs) {
|
|
245
|
+
if (sub.data.userId === userId) return // user still has another connection
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
members.delete(userId)
|
|
250
|
+
if (members.size === 0) {
|
|
251
|
+
this.presenceMembers.delete(channel)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Notify remaining subscribers
|
|
255
|
+
if (subs) {
|
|
256
|
+
const msg = serialize({ event: 'member:left', channel, data: { userId } })
|
|
257
|
+
for (const sub of subs) {
|
|
258
|
+
try { sub.send(msg) } catch { /* ignore */ }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
getPresenceMembers(channel: string): PresenceMember[] {
|
|
264
|
+
const members = this.presenceMembers.get(channel)
|
|
265
|
+
return members ? [...members.values()] : []
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Query ───────────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
getSubscribers(channel: string): RealtimeSocket[] {
|
|
271
|
+
return [...(this.subscriptions.get(channel) ?? [])]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
getChannels(): string[] {
|
|
275
|
+
return [...this.subscriptions.keys()]
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
subscriberCount(channel: string): number {
|
|
279
|
+
return this.subscriptions.get(channel)?.size ?? 0
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Private ─────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Find an authorizer that matches a channel base name.
|
|
286
|
+
* Supports glob-style `*` wildcards.
|
|
287
|
+
*/
|
|
288
|
+
private findAuthorizer(baseName: string): ChannelAuthorizer | null {
|
|
289
|
+
// Exact match first
|
|
290
|
+
if (this.authorizers.has(baseName)) {
|
|
291
|
+
return this.authorizers.get(baseName)!
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Wildcard match
|
|
295
|
+
for (const [pattern, callback] of this.authorizers) {
|
|
296
|
+
if (this.matchPattern(pattern, baseName)) {
|
|
297
|
+
return callback
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private matchPattern(pattern: string, name: string): boolean {
|
|
305
|
+
// Convert glob pattern to regex: "orders.*" → /^orders\.(.+)$/
|
|
306
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '(.+)')
|
|
307
|
+
return new RegExp(`^${escaped}$`).test(name)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel type classification based on name prefix.
|
|
3
|
+
*
|
|
4
|
+
* - `public` — no auth, anyone can subscribe (e.g. "chat.1")
|
|
5
|
+
* - `private` — auth required, server validates (e.g. "private:orders.5")
|
|
6
|
+
* - `presence` — auth + member tracking (e.g. "presence:room.3")
|
|
7
|
+
*/
|
|
8
|
+
export type ChannelType = 'public' | 'private' | 'presence'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Authorization callback for private/presence channels.
|
|
12
|
+
* Returns `true` to allow, `false` to deny, or an object for presence member info.
|
|
13
|
+
*/
|
|
14
|
+
export type ChannelAuthorizer = (
|
|
15
|
+
userId: string | number,
|
|
16
|
+
channelName: string,
|
|
17
|
+
metadata?: Record<string, any>,
|
|
18
|
+
) => boolean | Record<string, any> | Promise<boolean | Record<string, any>>
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Presence member info stored per connection.
|
|
22
|
+
*/
|
|
23
|
+
export interface PresenceMember {
|
|
24
|
+
userId: string | number
|
|
25
|
+
info: Record<string, any>
|
|
26
|
+
joinedAt: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a channel name into its type and base name.
|
|
31
|
+
*/
|
|
32
|
+
export function parseChannelName(name: string): { type: ChannelType; baseName: string } {
|
|
33
|
+
if (name.startsWith('presence:')) {
|
|
34
|
+
return { type: 'presence', baseName: name.slice(9) }
|
|
35
|
+
}
|
|
36
|
+
if (name.startsWith('private:')) {
|
|
37
|
+
return { type: 'private', baseName: name.slice(8) }
|
|
38
|
+
}
|
|
39
|
+
return { type: 'public', baseName: name }
|
|
40
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for @mantiq/realtime.
|
|
3
|
+
*/
|
|
4
|
+
export interface RealtimeConfig {
|
|
5
|
+
/** Enable/disable the realtime server. */
|
|
6
|
+
enabled: boolean
|
|
7
|
+
|
|
8
|
+
/** Broadcast driver: 'bun' (in-process) | 'redis' (multi-server) | 'log' | 'null'. */
|
|
9
|
+
driver: string
|
|
10
|
+
|
|
11
|
+
/** WebSocket server settings. */
|
|
12
|
+
websocket: {
|
|
13
|
+
/** Path for WebSocket connections. Clients connect to ws://host:port/<path>. */
|
|
14
|
+
path: string
|
|
15
|
+
/** Max connections per user (0 = unlimited). */
|
|
16
|
+
maxConnectionsPerUser: number
|
|
17
|
+
/** Max total connections (0 = unlimited). */
|
|
18
|
+
maxConnections: number
|
|
19
|
+
/** Heartbeat interval in ms. Server pings, client must pong. */
|
|
20
|
+
heartbeatInterval: number
|
|
21
|
+
/** Close connection if no pong after this many ms. */
|
|
22
|
+
heartbeatTimeout: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** SSE fallback settings. */
|
|
26
|
+
sse: {
|
|
27
|
+
enabled: boolean
|
|
28
|
+
/** Path for SSE connections. */
|
|
29
|
+
path: string
|
|
30
|
+
/** Keep-alive interval in ms. */
|
|
31
|
+
keepAliveInterval: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Presence channel settings. */
|
|
35
|
+
presence: {
|
|
36
|
+
/** How long to keep a member listed after disconnect (ms). */
|
|
37
|
+
memberTtl: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Redis driver settings (only used when driver is 'redis'). */
|
|
41
|
+
redis: {
|
|
42
|
+
host: string
|
|
43
|
+
port: number
|
|
44
|
+
password?: string
|
|
45
|
+
prefix: string
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_CONFIG: RealtimeConfig = {
|
|
50
|
+
enabled: true,
|
|
51
|
+
driver: 'bun',
|
|
52
|
+
websocket: {
|
|
53
|
+
path: '/ws',
|
|
54
|
+
maxConnectionsPerUser: 10,
|
|
55
|
+
maxConnections: 0,
|
|
56
|
+
heartbeatInterval: 25_000,
|
|
57
|
+
heartbeatTimeout: 10_000,
|
|
58
|
+
},
|
|
59
|
+
sse: {
|
|
60
|
+
enabled: true,
|
|
61
|
+
path: '/_sse',
|
|
62
|
+
keepAliveInterval: 15_000,
|
|
63
|
+
},
|
|
64
|
+
presence: {
|
|
65
|
+
memberTtl: 30_000,
|
|
66
|
+
},
|
|
67
|
+
redis: {
|
|
68
|
+
host: '127.0.0.1',
|
|
69
|
+
port: 6379,
|
|
70
|
+
prefix: 'mantiq_realtime:',
|
|
71
|
+
},
|
|
72
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { WebSocketServer } from '../server/WebSocketServer.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Symbol for container binding.
|
|
5
|
+
*/
|
|
6
|
+
export const REALTIME = Symbol('Realtime')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Internal reference set by RealtimeServiceProvider.boot().
|
|
10
|
+
*/
|
|
11
|
+
let _instance: WebSocketServer | null = null
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Set the singleton instance (called by the service provider).
|
|
15
|
+
*/
|
|
16
|
+
export function setRealtimeInstance(instance: WebSocketServer): void {
|
|
17
|
+
_instance = instance
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get the WebSocketServer instance.
|
|
22
|
+
*
|
|
23
|
+
* ```typescript
|
|
24
|
+
* import { realtime } from '@mantiq/realtime'
|
|
25
|
+
*
|
|
26
|
+
* // Register channel authorization
|
|
27
|
+
* realtime().channels.authorize('orders.*', async (userId, channel) => {
|
|
28
|
+
* return userId === getOrderOwner(channel)
|
|
29
|
+
* })
|
|
30
|
+
*
|
|
31
|
+
* // Broadcast from server code
|
|
32
|
+
* realtime().channels.broadcast('public:news', 'breaking', { title: '...' })
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function realtime(): WebSocketServer {
|
|
36
|
+
if (!_instance) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'Realtime not initialized. Register RealtimeServiceProvider in your application.',
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
return _instance
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @mantiq/realtime — public API exports
|
|
2
|
+
|
|
3
|
+
// ── Service Provider ────────────────────────────────────────────────────────
|
|
4
|
+
export { RealtimeServiceProvider } from './RealtimeServiceProvider.ts'
|
|
5
|
+
|
|
6
|
+
// ── Server ──────────────────────────────────────────────────────────────────
|
|
7
|
+
export { WebSocketServer } from './server/WebSocketServer.ts'
|
|
8
|
+
export { ConnectionManager } from './server/ConnectionManager.ts'
|
|
9
|
+
export type { RealtimeSocket } from './server/ConnectionManager.ts'
|
|
10
|
+
|
|
11
|
+
// ── Channels ────────────────────────────────────────────────────────────────
|
|
12
|
+
export { ChannelManager } from './channels/ChannelManager.ts'
|
|
13
|
+
|
|
14
|
+
// ── Broadcast ───────────────────────────────────────────────────────────────
|
|
15
|
+
export { BunBroadcaster } from './broadcast/BunBroadcaster.ts'
|
|
16
|
+
|
|
17
|
+
// ── SSE ─────────────────────────────────────────────────────────────────────
|
|
18
|
+
export { SSEManager } from './sse/SSEManager.ts'
|
|
19
|
+
export type { SSEConnection } from './sse/SSEManager.ts'
|
|
20
|
+
|
|
21
|
+
// ── Protocol ────────────────────────────────────────────────────────────────
|
|
22
|
+
export { parseClientMessage, serialize } from './protocol/Protocol.ts'
|
|
23
|
+
export type {
|
|
24
|
+
ClientMessage,
|
|
25
|
+
ServerMessage,
|
|
26
|
+
SubscribeMessage,
|
|
27
|
+
UnsubscribeMessage,
|
|
28
|
+
WhisperMessage,
|
|
29
|
+
BroadcastMessage,
|
|
30
|
+
MemberJoinedMessage,
|
|
31
|
+
MemberLeftMessage,
|
|
32
|
+
MemberHereMessage,
|
|
33
|
+
} from './protocol/Protocol.ts'
|
|
34
|
+
|
|
35
|
+
// ── Contracts ───────────────────────────────────────────────────────────────
|
|
36
|
+
export type { RealtimeConfig } from './contracts/RealtimeConfig.ts'
|
|
37
|
+
export { DEFAULT_CONFIG } from './contracts/RealtimeConfig.ts'
|
|
38
|
+
export { parseChannelName } from './contracts/Channel.ts'
|
|
39
|
+
export type { ChannelType, ChannelAuthorizer, PresenceMember } from './contracts/Channel.ts'
|
|
40
|
+
|
|
41
|
+
// ── Errors ──────────────────────────────────────────────────────────────────
|
|
42
|
+
export { RealtimeError } from './errors/RealtimeError.ts'
|
|
43
|
+
|
|
44
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
45
|
+
export { realtime, REALTIME } from './helpers/realtime.ts'
|
|
46
|
+
|
|
47
|
+
// ── Testing ─────────────────────────────────────────────────────────────────
|
|
48
|
+
export { RealtimeFake } from './testing/RealtimeFake.ts'
|