@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 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,6 @@
1
+ export class RealtimeError extends Error {
2
+ constructor(message: string, public readonly context?: Record<string, any>) {
3
+ super(message)
4
+ this.name = 'RealtimeError'
5
+ }
6
+ }
@@ -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'