@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,118 @@
1
+ export class ChannelManager {
2
+ constructor({ redis, pubClient, subClient, messageStream }) {
3
+ this.redis = redis
4
+ this.pubClient = pubClient
5
+ this.subClient = subClient
6
+ this.messageStream = messageStream
7
+ this.exposedChannels = []
8
+ this.channelGuards = new Map()
9
+ this.channelSubscriptions = {}
10
+ this.persistenceManager = null
11
+ }
12
+
13
+ setPersistenceManager(manager) {
14
+ this.persistenceManager = manager
15
+ }
16
+
17
+ exposeChannel(channel, guard) {
18
+ this.exposedChannels.push(channel)
19
+ if (guard) this.channelGuards.set(channel, guard)
20
+ }
21
+
22
+ async isChannelExposed(channel, connection) {
23
+ const matchedPattern = this.exposedChannels.find((pattern) =>
24
+ typeof pattern === "string" ? pattern === channel : pattern.test(channel)
25
+ )
26
+ if (!matchedPattern) return false
27
+ const guard = this.channelGuards.get(matchedPattern)
28
+ if (guard) {
29
+ try { return await Promise.resolve(guard(connection, channel)) }
30
+ catch { return false }
31
+ }
32
+ return true
33
+ }
34
+
35
+ async writeChannel(channel, message, history = 0, instanceId) {
36
+ const serialized = typeof message === "string" ? message : JSON.stringify(message)
37
+ const parsedHistory = parseInt(history, 10)
38
+ if (!isNaN(parsedHistory) && parsedHistory > 0) {
39
+ await this.pubClient.rpush(`mesh:history:${channel}`, serialized)
40
+ await this.pubClient.ltrim(`mesh:history:${channel}`, -parsedHistory, -1)
41
+ }
42
+ this.messageStream.publishMessage(channel, serialized, instanceId)
43
+ await this.pubClient.publish(channel, serialized)
44
+ }
45
+
46
+ addSubscription(channel, connection) {
47
+ if (!this.channelSubscriptions[channel]) {
48
+ this.channelSubscriptions[channel] = new Set()
49
+ }
50
+ this.channelSubscriptions[channel].add(connection)
51
+ }
52
+
53
+ removeSubscription(channel, connection) {
54
+ if (this.channelSubscriptions[channel]) {
55
+ this.channelSubscriptions[channel].delete(connection)
56
+ if (this.channelSubscriptions[channel].size === 0) {
57
+ delete this.channelSubscriptions[channel]
58
+ }
59
+ return true
60
+ }
61
+ return false
62
+ }
63
+
64
+ getSubscribers(channel) {
65
+ return this.channelSubscriptions[channel]
66
+ }
67
+
68
+ async subscribeToRedisChannel(channel) {
69
+ return new Promise((resolve, reject) => {
70
+ this.subClient.subscribe(channel, (err) => {
71
+ if (err) reject(err)
72
+ else resolve()
73
+ })
74
+ })
75
+ }
76
+
77
+ async unsubscribeFromRedisChannel(channel) {
78
+ return new Promise((resolve, reject) => {
79
+ this.subClient.unsubscribe(channel, (err) => {
80
+ if (err) reject(err)
81
+ else resolve()
82
+ })
83
+ })
84
+ }
85
+
86
+ async getChannelHistory(channel, limit, since) {
87
+ if (this.persistenceManager && since !== undefined) {
88
+ try {
89
+ const messages = await this.persistenceManager.getMessages(channel, since, limit)
90
+ return messages.map((msg) => msg.message)
91
+ } catch {
92
+ const historyKey = `mesh:history:${channel}`
93
+ return this.redis.lrange(historyKey, 0, limit - 1)
94
+ }
95
+ }
96
+ const historyKey = `mesh:history:${channel}`
97
+ return this.redis.lrange(historyKey, 0, limit - 1)
98
+ }
99
+
100
+ async getPersistedMessages(channel, since, limit) {
101
+ if (!this.persistenceManager) throw new Error("Persistence not enabled")
102
+ return this.persistenceManager.getMessages(channel, since, limit)
103
+ }
104
+
105
+ cleanupConnection(connection) {
106
+ for (const channel in this.channelSubscriptions) {
107
+ this.removeSubscription(channel, connection)
108
+ }
109
+ }
110
+
111
+ async cleanupAllSubscriptions() {
112
+ const channels = Object.keys(this.channelSubscriptions)
113
+ for (const channel of channels) {
114
+ try { await this.unsubscribeFromRedisChannel(channel) } catch {}
115
+ }
116
+ this.channelSubscriptions = {}
117
+ }
118
+ }
@@ -0,0 +1,127 @@
1
+ export class CollectionManager {
2
+ constructor({ redis, emitError }) {
3
+ this.redis = redis
4
+ this.emitError = emitError
5
+ this.exposedCollections = []
6
+ this.collectionSubscriptions = new Map()
7
+ }
8
+
9
+ exposeCollection(pattern, resolver) {
10
+ this.exposedCollections.push({ pattern, resolver })
11
+ }
12
+
13
+ async isCollectionExposed(collectionId, _connection) {
14
+ const matchedPattern = this.exposedCollections.find((entry) =>
15
+ typeof entry.pattern === "string" ? entry.pattern === collectionId : entry.pattern.test(collectionId)
16
+ )
17
+ return !!matchedPattern
18
+ }
19
+
20
+ async resolveCollection(collectionId, connection) {
21
+ const matchedPattern = this.exposedCollections.find((entry) =>
22
+ typeof entry.pattern === "string" ? entry.pattern === collectionId : entry.pattern.test(collectionId)
23
+ )
24
+ if (!matchedPattern) throw new Error(`Collection "${collectionId}" is not exposed`)
25
+ try {
26
+ return await Promise.resolve(matchedPattern.resolver(connection, collectionId))
27
+ } catch (error) {
28
+ this.emitError(new Error(`Failed to resolve collection "${collectionId}": ${error}`))
29
+ throw error
30
+ }
31
+ }
32
+
33
+ async addSubscription(collectionId, connectionId, connection) {
34
+ if (!this.collectionSubscriptions.has(collectionId)) {
35
+ this.collectionSubscriptions.set(collectionId, new Map())
36
+ }
37
+ const records = await this.resolveCollection(collectionId, connection)
38
+ const ids = records.map((record) => record.id)
39
+ const version = 1
40
+ this.collectionSubscriptions.get(collectionId).set(connectionId, { version })
41
+ await this.redis.set(`mesh:collection:${collectionId}:${connectionId}`, JSON.stringify(ids))
42
+ return { ids, records, version }
43
+ }
44
+
45
+ async removeSubscription(collectionId, connectionId) {
46
+ const collectionSubs = this.collectionSubscriptions.get(collectionId)
47
+ if (collectionSubs?.has(connectionId)) {
48
+ collectionSubs.delete(connectionId)
49
+ if (collectionSubs.size === 0) this.collectionSubscriptions.delete(collectionId)
50
+ await this.redis.del(`mesh:collection:${collectionId}:${connectionId}`)
51
+ return true
52
+ }
53
+ return false
54
+ }
55
+
56
+ async publishRecordChange(recordId) {
57
+ try {
58
+ await this.redis.publish("mesh:collection:record-change", recordId)
59
+ } catch (error) {
60
+ this.emitError(new Error(`Failed to publish record change for ${recordId}: ${error}`))
61
+ }
62
+ }
63
+
64
+ async cleanupConnection(connection) {
65
+ const connectionId = connection.id
66
+ const cleanupPromises = []
67
+ this.collectionSubscriptions.forEach((subscribers, collectionId) => {
68
+ if (!subscribers.has(connectionId)) return
69
+ subscribers.delete(connectionId)
70
+ if (subscribers.size === 0) this.collectionSubscriptions.delete(collectionId)
71
+ cleanupPromises.push(
72
+ this.redis.del(`mesh:collection:${collectionId}:${connectionId}`).then(() => {}).catch((err) => {
73
+ this.emitError(new Error(`Failed to clean up collection subscription for "${collectionId}": ${err}`))
74
+ })
75
+ )
76
+ })
77
+ await Promise.all(cleanupPromises)
78
+ }
79
+
80
+ async listRecordsMatching(pattern, options) {
81
+ try {
82
+ const recordKeyPrefix = "mesh:record:"
83
+ const keys = []
84
+ let cursor = "0"
85
+ do {
86
+ const result = await this.redis.scan(cursor, "MATCH", `${recordKeyPrefix}${pattern}`, "COUNT", options?.scanCount ?? 100)
87
+ cursor = result[0]
88
+ keys.push(...result[1])
89
+ } while (cursor !== "0")
90
+ if (keys.length === 0) return []
91
+ const records = await this.redis.mget(keys)
92
+ const cleanRecordIds = keys.map((key) => key.substring(recordKeyPrefix.length))
93
+ let processedRecords = records
94
+ .map((val, index) => {
95
+ if (val === null) return null
96
+ try {
97
+ const parsed = JSON.parse(val)
98
+ const recordId = cleanRecordIds[index]
99
+ return parsed.id === recordId ? parsed : { ...parsed, id: recordId }
100
+ } catch (e) {
101
+ this.emitError(new Error(`Failed to parse record for processing: ${val} - ${e.message}`))
102
+ return null
103
+ }
104
+ })
105
+ .filter((record) => record !== null)
106
+ if (options?.map) processedRecords = processedRecords.map(options.map)
107
+ if (options?.sort) processedRecords.sort(options.sort)
108
+ if (options?.slice) {
109
+ const { start, count } = options.slice
110
+ processedRecords = processedRecords.slice(start, start + count)
111
+ }
112
+ return processedRecords
113
+ } catch (error) {
114
+ this.emitError(new Error(`Failed to list records matching "${pattern}": ${error.message}`))
115
+ return []
116
+ }
117
+ }
118
+
119
+ getCollectionSubscriptions() { return this.collectionSubscriptions }
120
+
121
+ updateSubscriptionVersion(collectionId, connectionId, version) {
122
+ const collectionSubs = this.collectionSubscriptions.get(collectionId)
123
+ if (collectionSubs?.has(connectionId)) {
124
+ collectionSubs.set(connectionId, { version })
125
+ }
126
+ }
127
+ }
@@ -0,0 +1,55 @@
1
+ import { Context } from "../context.js"
2
+ import { CodeError } from "../../shared/index.js"
3
+
4
+ export class CommandManager {
5
+ constructor() {
6
+ this.commands = {}
7
+ this.globalMiddlewares = []
8
+ this.middlewares = {}
9
+ }
10
+
11
+ exposeCommand(command, callback, middlewares = []) {
12
+ this.commands[command] = callback
13
+ if (middlewares.length > 0) {
14
+ this.useMiddlewareWithCommand(command, middlewares)
15
+ }
16
+ }
17
+
18
+ useMiddleware(...middlewares) {
19
+ this.globalMiddlewares.push(...middlewares)
20
+ }
21
+
22
+ useMiddlewareWithCommand(command, middlewares) {
23
+ if (middlewares.length) {
24
+ this.middlewares[command] = this.middlewares[command] || []
25
+ this.middlewares[command] = middlewares.concat(this.middlewares[command])
26
+ }
27
+ }
28
+
29
+ async runCommand(id, commandName, payload, connection, server) {
30
+ const context = new Context(server, commandName, connection, payload)
31
+ try {
32
+ if (!this.commands[commandName]) {
33
+ throw new CodeError(`Command "${commandName}" not found`, "ENOTFOUND", "CommandError")
34
+ }
35
+ for (const middleware of this.globalMiddlewares) {
36
+ await middleware(context)
37
+ }
38
+ if (this.middlewares[commandName]) {
39
+ for (const middleware of this.middlewares[commandName]) {
40
+ await middleware(context)
41
+ }
42
+ }
43
+ const result = await this.commands[commandName](context)
44
+ connection.send({ id, command: commandName, payload: result })
45
+ } catch (err) {
46
+ const errorPayload = err instanceof Error
47
+ ? { error: err.message, code: err.code || "ESERVER", name: err.name || "Error" }
48
+ : { error: String(err), code: "EUNKNOWN", name: "UnknownError" }
49
+ connection.send({ id, command: commandName, payload: errorPayload })
50
+ }
51
+ }
52
+
53
+ getCommands() { return this.commands }
54
+ hasCommand(commandName) { return !!this.commands[commandName] }
55
+ }
@@ -0,0 +1,111 @@
1
+ import { deepMerge, isObject } from "../../shared/index.js"
2
+
3
+ const CONNECTIONS_HASH_KEY = "mesh:connections"
4
+ const CONNECTIONS_META_HASH_KEY = "mesh:connection-meta"
5
+ const INSTANCE_CONNECTIONS_KEY_PREFIX = "mesh:connections:"
6
+
7
+ export class ConnectionManager {
8
+ constructor({ redis, instanceId, roomManager }) {
9
+ this.redis = redis
10
+ this.instanceId = instanceId
11
+ this.roomManager = roomManager
12
+ this.localConnections = {}
13
+ }
14
+
15
+ getLocalConnections() {
16
+ return Object.values(this.localConnections)
17
+ }
18
+
19
+ getLocalConnection(id) {
20
+ return this.localConnections[id] ?? null
21
+ }
22
+
23
+ async registerConnection(connection) {
24
+ this.localConnections[connection.id] = connection
25
+ const pipeline = this.redis.pipeline()
26
+ pipeline.hset(CONNECTIONS_HASH_KEY, connection.id, this.instanceId)
27
+ pipeline.sadd(`${INSTANCE_CONNECTIONS_KEY_PREFIX}${this.instanceId}`, connection.id)
28
+ await pipeline.exec()
29
+ }
30
+
31
+ async deregisterConnection(connection) {
32
+ const instanceId = await this.redis.hget(CONNECTIONS_HASH_KEY, connection.id)
33
+ if (!instanceId) return
34
+ const pipeline = this.redis.pipeline()
35
+ pipeline.hdel(CONNECTIONS_HASH_KEY, connection.id)
36
+ pipeline.srem(`${INSTANCE_CONNECTIONS_KEY_PREFIX}${instanceId}`, connection.id)
37
+ await pipeline.exec()
38
+ }
39
+
40
+ async getInstanceIdsForConnections(connectionIds) {
41
+ if (connectionIds.length === 0) return {}
42
+ const instanceIds = await this.redis.hmget(CONNECTIONS_HASH_KEY, ...connectionIds)
43
+ const result = {}
44
+ connectionIds.forEach((id, index) => { result[id] = instanceIds[index] ?? null })
45
+ return result
46
+ }
47
+
48
+ async getAllConnectionIds() {
49
+ return this.redis.hkeys(CONNECTIONS_HASH_KEY)
50
+ }
51
+
52
+ async getLocalConnectionIds() {
53
+ return this.redis.smembers(`${INSTANCE_CONNECTIONS_KEY_PREFIX}${this.instanceId}`)
54
+ }
55
+
56
+ async setMetadata(connection, metadata, options) {
57
+ let finalMetadata
58
+ const strategy = options?.strategy || "replace"
59
+ if (strategy === "replace") {
60
+ finalMetadata = metadata
61
+ } else {
62
+ const existingMetadata = await this.getMetadata(connection)
63
+ if (strategy === "merge") {
64
+ finalMetadata = isObject(existingMetadata) && isObject(metadata)
65
+ ? { ...existingMetadata, ...metadata }
66
+ : metadata
67
+ } else if (strategy === "deepMerge") {
68
+ finalMetadata = isObject(existingMetadata) && isObject(metadata)
69
+ ? deepMerge(existingMetadata, metadata)
70
+ : metadata
71
+ }
72
+ }
73
+ const id = typeof connection === "string" ? connection : connection.id
74
+ await this.redis.hset(CONNECTIONS_META_HASH_KEY, id, JSON.stringify(finalMetadata))
75
+ }
76
+
77
+ async getMetadata(connection) {
78
+ const id = typeof connection === "string" ? connection : connection.id
79
+ const metadata = await this.redis.hget(CONNECTIONS_META_HASH_KEY, id)
80
+ return metadata ? JSON.parse(metadata) : null
81
+ }
82
+
83
+ async getAllMetadata() {
84
+ const connectionIds = await this.getAllConnectionIds()
85
+ return this._getMetadataForConnectionIds(connectionIds)
86
+ }
87
+
88
+ async getAllMetadataForRoom(roomName) {
89
+ const connectionIds = await this.roomManager.getRoomConnectionIds(roomName)
90
+ return this._getMetadataForConnectionIds(connectionIds)
91
+ }
92
+
93
+ async _getMetadataForConnectionIds(connectionIds) {
94
+ if (connectionIds.length === 0) return []
95
+ const values = await this.redis.hmget(CONNECTIONS_META_HASH_KEY, ...connectionIds)
96
+ return connectionIds.map((id, index) => {
97
+ try {
98
+ const raw = values[index]
99
+ return { id, metadata: raw ? JSON.parse(raw) : null }
100
+ } catch {
101
+ return { id, metadata: null }
102
+ }
103
+ })
104
+ }
105
+
106
+ async cleanupConnection(connection) {
107
+ delete this.localConnections[connection.id]
108
+ await this.deregisterConnection(connection)
109
+ await this.redis.hdel(CONNECTIONS_META_HASH_KEY, connection.id)
110
+ }
111
+ }
@@ -0,0 +1,125 @@
1
+ import { serverLogger } from "../../shared/index.js"
2
+
3
+ export class InstanceManager {
4
+ constructor({ redis, instanceId }) {
5
+ this.redis = redis
6
+ this.instanceId = instanceId
7
+ this.heartbeatInterval = null
8
+ this.heartbeatTTL = 120
9
+ this.heartbeatFrequency = 15000
10
+ this.cleanupInterval = null
11
+ this.cleanupFrequency = 60000
12
+ this.cleanupLockTTL = 10
13
+ }
14
+
15
+ async start() {
16
+ await this._registerInstance()
17
+ await this._updateHeartbeat()
18
+ this.heartbeatInterval = setInterval(() => this._updateHeartbeat(), this.heartbeatFrequency)
19
+ this.heartbeatInterval.unref()
20
+ this.cleanupInterval = setInterval(() => this._performCleanup(), this.cleanupFrequency)
21
+ this.cleanupInterval.unref()
22
+ }
23
+
24
+ async stop() {
25
+ if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null }
26
+ if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null }
27
+ await this._deregisterInstance()
28
+ }
29
+
30
+ async _registerInstance() {
31
+ await this.redis.sadd("mesh:instances", this.instanceId)
32
+ }
33
+
34
+ async _deregisterInstance() {
35
+ await this.redis.srem("mesh:instances", this.instanceId)
36
+ await this.redis.del(`mesh:instance:${this.instanceId}:heartbeat`)
37
+ }
38
+
39
+ async _updateHeartbeat() {
40
+ await this.redis.set(`mesh:instance:${this.instanceId}:heartbeat`, Date.now().toString(), "EX", this.heartbeatTTL)
41
+ }
42
+
43
+ async _acquireCleanupLock() {
44
+ const result = await this.redis.set("mesh:cleanup:lock", this.instanceId, "EX", this.cleanupLockTTL, "NX")
45
+ return result === "OK"
46
+ }
47
+
48
+ async _releaseCleanupLock() {
49
+ const script = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end'
50
+ await this.redis.eval(script, 1, "mesh:cleanup:lock", this.instanceId)
51
+ }
52
+
53
+ async _performCleanup() {
54
+ try {
55
+ const lockAcquired = await this._acquireCleanupLock()
56
+ if (!lockAcquired) return
57
+ const registeredInstances = await this.redis.smembers("mesh:instances")
58
+ const allConnections = await this.redis.hgetall("mesh:connections")
59
+ const instanceIds = new Set([...registeredInstances, ...Object.values(allConnections)])
60
+ for (const instanceId of instanceIds) {
61
+ if (instanceId === this.instanceId) continue
62
+ const heartbeat = await this.redis.get(`mesh:instance:${instanceId}:heartbeat`)
63
+ if (!heartbeat) {
64
+ serverLogger.info(`Found dead instance: ${instanceId}`)
65
+ await this._cleanupDeadInstance(instanceId)
66
+ }
67
+ }
68
+ } catch (error) {
69
+ serverLogger.error("Error during cleanup:", error)
70
+ } finally {
71
+ await this._releaseCleanupLock()
72
+ }
73
+ }
74
+
75
+ async _cleanupDeadInstance(instanceId) {
76
+ try {
77
+ const connectionsKey = `mesh:connections:${instanceId}`
78
+ const connections = await this.redis.smembers(connectionsKey)
79
+ for (const connectionId of connections) {
80
+ await this._cleanupConnection(connectionId)
81
+ }
82
+ const allConnections = await this.redis.hgetall("mesh:connections")
83
+ for (const [connectionId, connInstanceId] of Object.entries(allConnections)) {
84
+ if (connInstanceId === instanceId) await this._cleanupConnection(connectionId)
85
+ }
86
+ await this.redis.srem("mesh:instances", instanceId)
87
+ await this.redis.del(connectionsKey)
88
+ serverLogger.info(`Cleaned up dead instance: ${instanceId}`)
89
+ } catch (error) {
90
+ serverLogger.error(`Error cleaning up instance ${instanceId}:`, error)
91
+ }
92
+ }
93
+
94
+ async _deleteMatchingKeys(pattern) {
95
+ const stream = this.redis.scanStream({ match: pattern })
96
+ const pipeline = this.redis.pipeline()
97
+ stream.on("data", (keys) => { for (const key of keys) pipeline.del(key) })
98
+ return new Promise((resolve, reject) => {
99
+ stream.on("end", async () => { await pipeline.exec(); resolve() })
100
+ stream.on("error", reject)
101
+ })
102
+ }
103
+
104
+ async _cleanupConnection(connectionId) {
105
+ try {
106
+ const roomsKey = `mesh:connection:${connectionId}:rooms`
107
+ const rooms = await this.redis.smembers(roomsKey)
108
+ const pipeline = this.redis.pipeline()
109
+ for (const room of rooms) {
110
+ pipeline.srem(`mesh:room:${room}`, connectionId)
111
+ pipeline.srem(`mesh:presence:room:${room}`, connectionId)
112
+ pipeline.del(`mesh:presence:room:${room}:conn:${connectionId}`)
113
+ pipeline.del(`mesh:presence:state:${room}:conn:${connectionId}`)
114
+ }
115
+ pipeline.del(roomsKey)
116
+ pipeline.hdel("mesh:connections", connectionId)
117
+ pipeline.hdel("mesh:connection-meta", connectionId)
118
+ await this._deleteMatchingKeys(`mesh:collection:*:${connectionId}`)
119
+ await pipeline.exec()
120
+ serverLogger.debug(`Cleaned up stale connection: ${connectionId}`)
121
+ } catch (error) {
122
+ serverLogger.error(`Error cleaning up connection ${connectionId}:`, error)
123
+ }
124
+ }
125
+ }