@prsm/realtime 1.0.0

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.
Files changed (60) hide show
  1. package/package.json +49 -0
  2. package/src/adapters/postgres.js +139 -0
  3. package/src/adapters/sqlite.js +177 -0
  4. package/src/client/client.js +441 -0
  5. package/src/client/connection.js +178 -0
  6. package/src/client/ids.js +26 -0
  7. package/src/client/index.js +5 -0
  8. package/src/client/queue.js +44 -0
  9. package/src/client/subscriptions/channels.js +79 -0
  10. package/src/client/subscriptions/collections.js +96 -0
  11. package/src/client/subscriptions/presence.js +98 -0
  12. package/src/client/subscriptions/records.js +123 -0
  13. package/src/client/subscriptions/rooms.js +69 -0
  14. package/src/devtools/client/dist/assets/index-CGm1NqOQ.css +1 -0
  15. package/src/devtools/client/dist/assets/index-w2FI7RvC.js +168 -0
  16. package/src/devtools/client/dist/index.html +16 -0
  17. package/src/devtools/client/index.html +15 -0
  18. package/src/devtools/client/package.json +17 -0
  19. package/src/devtools/client/src/App.vue +173 -0
  20. package/src/devtools/client/src/components/ConnectionPicker.vue +38 -0
  21. package/src/devtools/client/src/components/JsonView.vue +18 -0
  22. package/src/devtools/client/src/composables/useApi.js +71 -0
  23. package/src/devtools/client/src/composables/useHighlight.js +57 -0
  24. package/src/devtools/client/src/main.js +5 -0
  25. package/src/devtools/client/src/style.css +440 -0
  26. package/src/devtools/client/src/views/ChannelsView.vue +27 -0
  27. package/src/devtools/client/src/views/CollectionsView.vue +61 -0
  28. package/src/devtools/client/src/views/MetadataView.vue +108 -0
  29. package/src/devtools/client/src/views/RecordsView.vue +30 -0
  30. package/src/devtools/client/src/views/RoomsView.vue +39 -0
  31. package/src/devtools/client/vite.config.js +17 -0
  32. package/src/devtools/demo/server.js +144 -0
  33. package/src/devtools/index.js +186 -0
  34. package/src/index.js +9 -0
  35. package/src/server/connection.js +116 -0
  36. package/src/server/context.js +22 -0
  37. package/src/server/managers/broadcast.js +94 -0
  38. package/src/server/managers/channels.js +118 -0
  39. package/src/server/managers/collections.js +127 -0
  40. package/src/server/managers/commands.js +55 -0
  41. package/src/server/managers/connections.js +111 -0
  42. package/src/server/managers/instance.js +125 -0
  43. package/src/server/managers/persistence.js +371 -0
  44. package/src/server/managers/presence.js +217 -0
  45. package/src/server/managers/pubsub.js +242 -0
  46. package/src/server/managers/record-subscriptions.js +123 -0
  47. package/src/server/managers/records.js +110 -0
  48. package/src/server/managers/redis.js +61 -0
  49. package/src/server/managers/rooms.js +129 -0
  50. package/src/server/message-stream.js +20 -0
  51. package/src/server/server.js +878 -0
  52. package/src/server/utils/constants.js +4 -0
  53. package/src/server/utils/ids.js +5 -0
  54. package/src/server/utils/pattern-conversion.js +14 -0
  55. package/src/shared/errors.js +7 -0
  56. package/src/shared/index.js +5 -0
  57. package/src/shared/logger.js +53 -0
  58. package/src/shared/merge.js +17 -0
  59. package/src/shared/message.js +17 -0
  60. package/src/shared/status.js +7 -0
@@ -0,0 +1,371 @@
1
+ import { EventEmitter } from "node:events"
2
+ import { randomUUID } from "node:crypto"
3
+ import { serverLogger } from "../../shared/index.js"
4
+
5
+ export class PersistenceManager extends EventEmitter {
6
+ constructor({ adapter }) {
7
+ super()
8
+ this.defaultAdapter = adapter
9
+ this.channelPatterns = []
10
+ this.recordPatterns = []
11
+ this.messageBuffer = new Map()
12
+ this.recordBuffer = new Map()
13
+ this.flushTimers = new Map()
14
+ this.recordFlushTimer = null
15
+ this.isShuttingDown = false
16
+ this.initialized = false
17
+ this.recordManager = null
18
+ this.pendingRecordUpdates = []
19
+ this.messageStream = null
20
+ }
21
+
22
+ setMessageStream(messageStream) {
23
+ this.messageStream = messageStream
24
+ }
25
+
26
+ setRecordManager(recordManager) {
27
+ this.recordManager = recordManager
28
+ }
29
+
30
+ async ready() {
31
+ if (this.initialized) return
32
+ return new Promise((resolve) => { this.once("initialized", resolve) })
33
+ }
34
+
35
+ async _processPendingRecordUpdates() {
36
+ if (this.pendingRecordUpdates.length === 0) return
37
+ serverLogger.info(`Processing ${this.pendingRecordUpdates.length} pending record updates`)
38
+ const updates = [...this.pendingRecordUpdates]
39
+ this.pendingRecordUpdates = []
40
+ for (const { recordId, value, version } of updates) {
41
+ this.handleRecordUpdate(recordId, value, version)
42
+ }
43
+ }
44
+
45
+ async initialize() {
46
+ if (this.initialized) return
47
+ try {
48
+ if (this.defaultAdapter) await this.defaultAdapter.initialize()
49
+ if (this.messageStream) {
50
+ this._boundHandleStreamMessage = this._handleStreamMessage.bind(this)
51
+ this.messageStream.subscribeToMessages(this._boundHandleStreamMessage)
52
+ }
53
+ this.initialized = true
54
+ await this._processPendingRecordUpdates()
55
+ this.emit("initialized")
56
+ } catch (err) {
57
+ serverLogger.error("Failed to initialize persistence manager:", err)
58
+ throw err
59
+ }
60
+ }
61
+
62
+ async restorePersistedRecords() {
63
+ if (!this.recordManager) {
64
+ serverLogger.warn("Cannot restore persisted records: record manager not available")
65
+ return
66
+ }
67
+ const redis = this.recordManager.getRedis()
68
+ if (!redis) {
69
+ serverLogger.warn("Cannot restore records: Redis not available")
70
+ return
71
+ }
72
+ try {
73
+ serverLogger.info("Restoring persisted records...")
74
+ if (this.recordPatterns.length === 0) {
75
+ serverLogger.info("No record patterns to restore")
76
+ return
77
+ }
78
+ for (const config of this.recordPatterns) {
79
+ const { adapter, hooks } = config
80
+ const patternLabel = hooks ? "(custom hooks)" : adapter?.restorePattern
81
+ try {
82
+ let records = []
83
+ if (hooks) {
84
+ records = await hooks.restore()
85
+ } else if (adapter) {
86
+ const adapterRecords = adapter.adapter.getRecords
87
+ ? await adapter.adapter.getRecords(adapter.restorePattern)
88
+ : []
89
+ records = adapterRecords.map((r) => ({
90
+ recordId: r.recordId,
91
+ value: typeof r.value === "string" ? JSON.parse(r.value) : r.value,
92
+ version: r.version,
93
+ }))
94
+ }
95
+ if (records.length > 0) {
96
+ serverLogger.info(`Restoring ${records.length} records for pattern ${patternLabel}`)
97
+ for (const record of records) {
98
+ try {
99
+ const { recordId, value, version } = record
100
+ const recordKey = this.recordManager.recordKey(recordId)
101
+ const versionKey = this.recordManager.recordVersionKey(recordId)
102
+ const pipeline = redis.pipeline()
103
+ pipeline.set(recordKey, JSON.stringify(value))
104
+ pipeline.set(versionKey, version.toString())
105
+ await pipeline.exec()
106
+ } catch (parseErr) {
107
+ serverLogger.error(`Failed to restore record ${record.recordId}: ${parseErr}`)
108
+ }
109
+ }
110
+ }
111
+ } catch (patternErr) {
112
+ serverLogger.error(`Error restoring records for pattern ${patternLabel}: ${patternErr}`)
113
+ }
114
+ }
115
+ serverLogger.info("Finished restoring persisted records")
116
+ } catch (err) {
117
+ serverLogger.error("Failed to restore persisted records:", err)
118
+ }
119
+ }
120
+
121
+ _handleStreamMessage(message) {
122
+ const { channel, message: messageContent, instanceId, timestamp } = message
123
+ this._handleChannelMessage(channel, messageContent, instanceId, timestamp)
124
+ }
125
+
126
+ enableChannelPersistence(pattern, options = {}) {
127
+ const fullOptions = {
128
+ historyLimit: options.historyLimit ?? 50,
129
+ filter: options.filter ?? (() => true),
130
+ adapter: options.adapter ?? this.defaultAdapter,
131
+ flushInterval: options.flushInterval ?? 500,
132
+ maxBufferSize: options.maxBufferSize ?? 100,
133
+ }
134
+ if (fullOptions.adapter !== this.defaultAdapter && !this.isShuttingDown) {
135
+ fullOptions.adapter.initialize().catch((err) => {
136
+ serverLogger.error(`Failed to initialize adapter for pattern ${pattern}:`, err)
137
+ })
138
+ }
139
+ this.channelPatterns.push({ pattern, options: fullOptions })
140
+ }
141
+
142
+ enableRecordPersistence(config) {
143
+ const { pattern, adapter, hooks, flushInterval, maxBufferSize } = config
144
+ if (adapter && hooks) throw new Error("Cannot use both adapter and hooks. Choose one.")
145
+ let resolvedAdapter
146
+ if (adapter) {
147
+ const adapterInstance = adapter.adapter ?? this.defaultAdapter
148
+ resolvedAdapter = { adapter: adapterInstance, restorePattern: adapter.restorePattern }
149
+ if (adapterInstance !== this.defaultAdapter && !this.isShuttingDown) {
150
+ adapterInstance.initialize().catch((err) => {
151
+ serverLogger.error(`Failed to initialize adapter for record pattern ${pattern}:`, err)
152
+ })
153
+ }
154
+ }
155
+ this.recordPatterns.push({
156
+ pattern,
157
+ adapter: resolvedAdapter,
158
+ hooks,
159
+ flushInterval: flushInterval ?? 500,
160
+ maxBufferSize: maxBufferSize ?? 100,
161
+ })
162
+ }
163
+
164
+ getChannelPersistenceOptions(channel) {
165
+ for (const { pattern, options } of this.channelPatterns) {
166
+ if ((typeof pattern === "string" && pattern === channel) || (pattern instanceof RegExp && pattern.test(channel))) {
167
+ return options
168
+ }
169
+ }
170
+ return undefined
171
+ }
172
+
173
+ getRecordPersistenceConfig(recordId) {
174
+ for (const config of this.recordPatterns) {
175
+ const { pattern } = config
176
+ if ((typeof pattern === "string" && pattern === recordId) || (pattern instanceof RegExp && pattern.test(recordId))) {
177
+ return config
178
+ }
179
+ }
180
+ return undefined
181
+ }
182
+
183
+ _handleChannelMessage(channel, message, instanceId, timestamp) {
184
+ if (!this.initialized || this.isShuttingDown) return
185
+ const options = this.getChannelPersistenceOptions(channel)
186
+ if (!options) return
187
+ if (!options.filter(message, channel)) return
188
+ const persistedMessage = {
189
+ id: randomUUID(),
190
+ channel,
191
+ message,
192
+ instanceId,
193
+ timestamp: timestamp || Date.now(),
194
+ }
195
+ if (!this.messageBuffer.has(channel)) this.messageBuffer.set(channel, [])
196
+ this.messageBuffer.get(channel).push(persistedMessage)
197
+ if (this.messageBuffer.get(channel).length >= options.maxBufferSize) {
198
+ this._flushChannel(channel)
199
+ return
200
+ }
201
+ if (!this.flushTimers.has(channel)) {
202
+ const timer = setTimeout(() => { this._flushChannel(channel) }, options.flushInterval)
203
+ if (timer.unref) timer.unref()
204
+ this.flushTimers.set(channel, timer)
205
+ }
206
+ }
207
+
208
+ async _flushChannel(channel) {
209
+ if (!this.messageBuffer.has(channel)) return
210
+ if (this.flushTimers.has(channel)) {
211
+ clearTimeout(this.flushTimers.get(channel))
212
+ this.flushTimers.delete(channel)
213
+ }
214
+ const messages = this.messageBuffer.get(channel)
215
+ if (messages.length === 0) return
216
+ this.messageBuffer.set(channel, [])
217
+ const options = this.getChannelPersistenceOptions(channel)
218
+ if (!options) return
219
+ try {
220
+ await options.adapter.storeMessages(messages)
221
+ this.emit("flushed", { channel, count: messages.length })
222
+ } catch (err) {
223
+ serverLogger.error(`Failed to flush messages for channel ${channel}:`, err)
224
+ if (!this.isShuttingDown) {
225
+ const currentMessages = this.messageBuffer.get(channel) || []
226
+ this.messageBuffer.set(channel, [...messages, ...currentMessages])
227
+ if (!this.flushTimers.has(channel)) {
228
+ const timer = setTimeout(() => { this._flushChannel(channel) }, 1000)
229
+ if (timer.unref) timer.unref()
230
+ this.flushTimers.set(channel, timer)
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ async flushAll() {
237
+ const channels = Array.from(this.messageBuffer.keys())
238
+ for (const channel of channels) {
239
+ await this._flushChannel(channel)
240
+ }
241
+ }
242
+
243
+ async getMessages(channel, since, limit) {
244
+ if (!this.initialized) throw new Error("Persistence manager not initialized")
245
+ const options = this.getChannelPersistenceOptions(channel)
246
+ if (!options) throw new Error(`Channel ${channel} does not have persistence enabled`)
247
+ await this._flushChannel(channel)
248
+ return options.adapter.getMessages(channel, since, limit || options.historyLimit)
249
+ }
250
+
251
+ handleRecordUpdate(recordId, value, version) {
252
+ if (this.isShuttingDown) return
253
+ if (!this.initialized) {
254
+ this.pendingRecordUpdates.push({ recordId, value, version })
255
+ return
256
+ }
257
+ const config = this.getRecordPersistenceConfig(recordId)
258
+ if (!config) return
259
+ const persistedRecord = {
260
+ recordId,
261
+ value: JSON.stringify(value),
262
+ version,
263
+ timestamp: Date.now(),
264
+ }
265
+ this.recordBuffer.set(recordId, persistedRecord)
266
+ if (this.recordBuffer.size >= config.maxBufferSize) {
267
+ this.flushRecords()
268
+ return
269
+ }
270
+ if (!this.recordFlushTimer) {
271
+ this.recordFlushTimer = setTimeout(() => { this.flushRecords() }, config.flushInterval)
272
+ if (this.recordFlushTimer.unref) this.recordFlushTimer.unref()
273
+ }
274
+ }
275
+
276
+ async flushRecords() {
277
+ if (this.recordBuffer.size === 0) return
278
+ if (this.recordFlushTimer) {
279
+ clearTimeout(this.recordFlushTimer)
280
+ this.recordFlushTimer = null
281
+ }
282
+ const records = Array.from(this.recordBuffer.values())
283
+ this.recordBuffer.clear()
284
+ const recordsByAdapter = new Map()
285
+ const recordsByPersistFn = new Map()
286
+ for (const record of records) {
287
+ const config = this.getRecordPersistenceConfig(record.recordId)
288
+ if (!config) continue
289
+ if (config.hooks) {
290
+ if (!recordsByPersistFn.has(config.hooks.persist)) recordsByPersistFn.set(config.hooks.persist, [])
291
+ recordsByPersistFn.get(config.hooks.persist).push(record)
292
+ } else if (config.adapter) {
293
+ if (!recordsByAdapter.has(config.adapter.adapter)) recordsByAdapter.set(config.adapter.adapter, [])
294
+ recordsByAdapter.get(config.adapter.adapter).push(record)
295
+ }
296
+ }
297
+ const handleFlushError = (failedRecords, err) => {
298
+ serverLogger.error("Failed to flush records:", err)
299
+ if (!this.isShuttingDown) {
300
+ for (const record of failedRecords) this.recordBuffer.set(record.recordId, record)
301
+ if (!this.recordFlushTimer) {
302
+ this.recordFlushTimer = setTimeout(() => { this.flushRecords() }, 1000)
303
+ if (this.recordFlushTimer.unref) this.recordFlushTimer.unref()
304
+ }
305
+ }
306
+ }
307
+ for (const [persistFn, persistRecords] of recordsByPersistFn.entries()) {
308
+ try {
309
+ const customRecords = persistRecords.map((r) => ({
310
+ recordId: r.recordId,
311
+ value: JSON.parse(r.value),
312
+ version: r.version,
313
+ }))
314
+ await persistFn(customRecords)
315
+ this.emit("recordsFlushed", { count: persistRecords.length })
316
+ } catch (err) {
317
+ handleFlushError(persistRecords, err)
318
+ }
319
+ }
320
+ for (const [adapter, adapterRecords] of recordsByAdapter.entries()) {
321
+ try {
322
+ if (adapter.storeRecords) {
323
+ await adapter.storeRecords(adapterRecords)
324
+ this.emit("recordsFlushed", { count: adapterRecords.length })
325
+ }
326
+ } catch (err) {
327
+ handleFlushError(adapterRecords, err)
328
+ }
329
+ }
330
+ }
331
+
332
+ async getPersistedRecords(pattern) {
333
+ if (!this.initialized) throw new Error("Persistence manager not initialized")
334
+ await this.flushRecords()
335
+ try {
336
+ if (this.defaultAdapter?.getRecords) {
337
+ return await this.defaultAdapter.getRecords(pattern)
338
+ }
339
+ } catch (err) {
340
+ serverLogger.error(`Failed to get persisted records for pattern ${pattern}:`, err)
341
+ }
342
+ return []
343
+ }
344
+
345
+ async shutdown() {
346
+ if (this.isShuttingDown) return
347
+ this.isShuttingDown = true
348
+ if (this._boundHandleStreamMessage && this.messageStream) {
349
+ this.messageStream.unsubscribeFromMessages(this._boundHandleStreamMessage)
350
+ }
351
+ for (const timer of this.flushTimers.values()) clearTimeout(timer)
352
+ this.flushTimers.clear()
353
+ if (this.recordFlushTimer) {
354
+ clearTimeout(this.recordFlushTimer)
355
+ this.recordFlushTimer = null
356
+ }
357
+ await this.flushAll()
358
+ await this.flushRecords()
359
+ const adapters = new Set()
360
+ if (this.defaultAdapter) adapters.add(this.defaultAdapter)
361
+ for (const { options } of this.channelPatterns) adapters.add(options.adapter)
362
+ for (const config of this.recordPatterns) {
363
+ if (config.adapter) adapters.add(config.adapter.adapter)
364
+ }
365
+ for (const adapter of adapters) {
366
+ try { await adapter.close() }
367
+ catch (err) { serverLogger.error("Error closing persistence adapter:", err) }
368
+ }
369
+ this.initialized = false
370
+ }
371
+ }
@@ -0,0 +1,217 @@
1
+ import { serverLogger } from "../../shared/index.js"
2
+
3
+ export class PresenceManager {
4
+ constructor({ redis, roomManager, redisManager, enableExpirationEvents = true }) {
5
+ this.redis = redis
6
+ this.roomManager = roomManager
7
+ this.redisManager = redisManager
8
+ this.presenceExpirationEventsEnabled = enableExpirationEvents
9
+
10
+ this.PRESENCE_KEY_PATTERN = /^mesh:presence:room:(.+):conn:(.+)$/
11
+ this.PRESENCE_STATE_KEY_PATTERN = /^mesh:presence:state:(.+):conn:(.+)$/
12
+ this.trackedRooms = []
13
+ this.roomGuards = new Map()
14
+ this.roomTTLs = new Map()
15
+ this.defaultTTL = 0
16
+
17
+ if (this.presenceExpirationEventsEnabled) {
18
+ this._subscribeToExpirationEvents()
19
+ }
20
+ }
21
+
22
+ _getExpiredEventsPattern() {
23
+ const dbIndex = this.redis.options?.db ?? 0
24
+ return `__keyevent@${dbIndex}__:expired`
25
+ }
26
+
27
+ _subscribeToExpirationEvents() {
28
+ const { subClient } = this.redisManager
29
+ const pattern = this._getExpiredEventsPattern()
30
+ subClient.psubscribe(pattern)
31
+ subClient.on("pmessage", (_pattern, _channel, key) => {
32
+ if (this.PRESENCE_KEY_PATTERN.test(key) || this.PRESENCE_STATE_KEY_PATTERN.test(key)) {
33
+ this._handleExpiredKey(key)
34
+ }
35
+ })
36
+ }
37
+
38
+ async _handleExpiredKey(key) {
39
+ try {
40
+ let match = key.match(this.PRESENCE_KEY_PATTERN)
41
+ if (match && match[1] && match[2]) {
42
+ await this.markOffline(match[2], match[1])
43
+ return
44
+ }
45
+ match = key.match(this.PRESENCE_STATE_KEY_PATTERN)
46
+ if (match && match[1] && match[2]) {
47
+ await this._publishPresenceStateUpdate(match[1], match[2], null)
48
+ }
49
+ } catch (err) {
50
+ serverLogger.error("[PresenceManager] Failed to handle expired key:", err)
51
+ }
52
+ }
53
+
54
+ trackRoom(roomPattern, guardOrOptions) {
55
+ this.trackedRooms.push(roomPattern)
56
+ if (typeof guardOrOptions === "function") {
57
+ this.roomGuards.set(roomPattern, guardOrOptions)
58
+ } else if (guardOrOptions && typeof guardOrOptions === "object") {
59
+ if (guardOrOptions.guard) this.roomGuards.set(roomPattern, guardOrOptions.guard)
60
+ if (guardOrOptions.ttl && typeof guardOrOptions.ttl === "number") {
61
+ this.roomTTLs.set(roomPattern, guardOrOptions.ttl)
62
+ }
63
+ }
64
+ }
65
+
66
+ async isRoomTracked(roomName, connection) {
67
+ const matchedPattern = this.trackedRooms.find((pattern) =>
68
+ typeof pattern === "string" ? pattern === roomName : pattern.test(roomName)
69
+ )
70
+ if (!matchedPattern) return false
71
+ if (connection) {
72
+ const guard = this.roomGuards.get(matchedPattern)
73
+ if (guard) {
74
+ try { return await Promise.resolve(guard(connection, roomName)) }
75
+ catch { return false }
76
+ }
77
+ }
78
+ return true
79
+ }
80
+
81
+ getRoomTTL(roomName) {
82
+ const matchedPattern = this.trackedRooms.find((pattern) =>
83
+ typeof pattern === "string" ? pattern === roomName : pattern.test(roomName)
84
+ )
85
+ if (matchedPattern) {
86
+ const ttl = this.roomTTLs.get(matchedPattern)
87
+ if (ttl !== undefined) return ttl
88
+ }
89
+ return this.defaultTTL
90
+ }
91
+
92
+ presenceRoomKey(roomName) { return `mesh:presence:room:${roomName}` }
93
+ presenceConnectionKey(roomName, connectionId) { return `mesh:presence:room:${roomName}:conn:${connectionId}` }
94
+ presenceStateKey(roomName, connectionId) { return `mesh:presence:state:${roomName}:conn:${connectionId}` }
95
+
96
+ async markOnline(connectionId, roomName) {
97
+ const roomKey = this.presenceRoomKey(roomName)
98
+ const connKey = this.presenceConnectionKey(roomName, connectionId)
99
+ const ttl = this.getRoomTTL(roomName)
100
+ const pipeline = this.redis.pipeline()
101
+ pipeline.sadd(roomKey, connectionId)
102
+ if (ttl > 0) {
103
+ const ttlSeconds = Math.max(1, Math.floor(ttl / 1000))
104
+ pipeline.set(connKey, "", "EX", ttlSeconds)
105
+ } else {
106
+ pipeline.set(connKey, "")
107
+ }
108
+ await pipeline.exec()
109
+ await this._publishPresenceUpdate(roomName, connectionId, "join")
110
+ }
111
+
112
+ async markOffline(connectionId, roomName) {
113
+ const roomKey = this.presenceRoomKey(roomName)
114
+ const connKey = this.presenceConnectionKey(roomName, connectionId)
115
+ const stateKey = this.presenceStateKey(roomName, connectionId)
116
+ const pipeline = this.redis.pipeline()
117
+ pipeline.srem(roomKey, connectionId)
118
+ pipeline.del(connKey)
119
+ pipeline.del(stateKey)
120
+ await pipeline.exec()
121
+ await this._publishPresenceUpdate(roomName, connectionId, "leave")
122
+ }
123
+
124
+ async refreshPresence(connectionId, roomName) {
125
+ const connKey = this.presenceConnectionKey(roomName, connectionId)
126
+ const ttl = this.getRoomTTL(roomName)
127
+ if (ttl > 0) {
128
+ const ttlSeconds = Math.max(1, Math.floor(ttl / 1000))
129
+ await this.redis.set(connKey, "", "EX", ttlSeconds)
130
+ } else {
131
+ await this.redis.set(connKey, "")
132
+ }
133
+ }
134
+
135
+ async getPresentConnections(roomName) {
136
+ return this.redis.smembers(this.presenceRoomKey(roomName))
137
+ }
138
+
139
+ async _publishPresenceUpdate(roomName, connectionId, type) {
140
+ const channel = `mesh:presence:updates:${roomName}`
141
+ const message = JSON.stringify({ type, connectionId, roomName, timestamp: Date.now() })
142
+ await this.redis.publish(channel, message)
143
+ }
144
+
145
+ async publishPresenceState(connectionId, roomName, state, expireAfter, silent) {
146
+ const key = this.presenceStateKey(roomName, connectionId)
147
+ const value = JSON.stringify(state)
148
+ const pipeline = this.redis.pipeline()
149
+ if (expireAfter && expireAfter > 0) {
150
+ pipeline.set(key, value, "PX", expireAfter)
151
+ } else {
152
+ pipeline.set(key, value)
153
+ }
154
+ await pipeline.exec()
155
+ if (silent) return
156
+ await this._publishPresenceStateUpdate(roomName, connectionId, state)
157
+ }
158
+
159
+ async clearPresenceState(connectionId, roomName) {
160
+ const key = this.presenceStateKey(roomName, connectionId)
161
+ await this.redis.del(key)
162
+ await this._publishPresenceStateUpdate(roomName, connectionId, null)
163
+ }
164
+
165
+ async getPresenceState(connectionId, roomName) {
166
+ const key = this.presenceStateKey(roomName, connectionId)
167
+ const value = await this.redis.get(key)
168
+ if (!value) return null
169
+ try { return JSON.parse(value) }
170
+ catch (e) { serverLogger.error(`[PresenceManager] Failed to parse presence state: ${e}`); return null }
171
+ }
172
+
173
+ async getAllPresenceStates(roomName) {
174
+ const result = new Map()
175
+ const connections = await this.getPresentConnections(roomName)
176
+ if (connections.length === 0) return result
177
+ const pipeline = this.redis.pipeline()
178
+ for (const connectionId of connections) {
179
+ pipeline.get(this.presenceStateKey(roomName, connectionId))
180
+ }
181
+ const responses = await pipeline.exec()
182
+ if (!responses) return result
183
+ for (let i = 0; i < connections.length; i++) {
184
+ const connectionId = connections[i]
185
+ if (!connectionId) continue
186
+ const [err, value] = responses[i] || []
187
+ if (err || !value) continue
188
+ try { result.set(connectionId, JSON.parse(value)) }
189
+ catch (e) { serverLogger.error(`[PresenceManager] Failed to parse presence state: ${e}`) }
190
+ }
191
+ return result
192
+ }
193
+
194
+ async _publishPresenceStateUpdate(roomName, connectionId, state) {
195
+ const channel = `mesh:presence:updates:${roomName}`
196
+ const message = JSON.stringify({ type: "state", connectionId, roomName, state, timestamp: Date.now() })
197
+ await this.redis.publish(channel, message)
198
+ }
199
+
200
+ async cleanupConnection(connection) {
201
+ const connectionId = connection.id
202
+ const rooms = await this.roomManager.getRoomsForConnection(connectionId)
203
+ for (const roomName of rooms) {
204
+ if (await this.isRoomTracked(roomName)) {
205
+ await this.markOffline(connectionId, roomName)
206
+ }
207
+ }
208
+ }
209
+
210
+ async cleanup() {
211
+ const { subClient } = this.redisManager
212
+ if (subClient && subClient.status !== "end") {
213
+ const pattern = this._getExpiredEventsPattern()
214
+ await new Promise((resolve) => { subClient.punsubscribe(pattern, () => resolve()) })
215
+ }
216
+ }
217
+ }