@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,242 @@
1
+ import { serverLogger } from "../../shared/index.js"
2
+ import { PUB_SUB_CHANNEL_PREFIX, RECORD_PUB_SUB_CHANNEL } from "../utils/constants.js"
3
+
4
+ export class PubSubManager {
5
+ constructor({ subClient, pubClient, instanceId, connectionManager, recordManager, recordSubscriptions, getChannelSubscriptions, emitError, collectionManager }) {
6
+ this.subClient = subClient
7
+ this.pubClient = pubClient
8
+ this.instanceId = instanceId
9
+ this.connectionManager = connectionManager
10
+ this.recordManager = recordManager
11
+ this.recordSubscriptions = recordSubscriptions
12
+ this.getChannelSubscriptions = getChannelSubscriptions
13
+ this.emitError = emitError
14
+ this.collectionManager = collectionManager || null
15
+
16
+ this.collectionUpdateTimeouts = new Map()
17
+ this.collectionMaxDelayTimeouts = new Map()
18
+ this.pendingCollectionUpdates = new Map()
19
+ this.COLLECTION_UPDATE_DEBOUNCE_MS = 50
20
+ this.COLLECTION_MAX_DELAY_MS = 200
21
+ }
22
+
23
+ subscribeToInstanceChannel() {
24
+ const channel = `${PUB_SUB_CHANNEL_PREFIX}${this.instanceId}`
25
+ this._subscriptionPromise = new Promise((resolve, reject) => {
26
+ this.subClient.subscribe(channel, RECORD_PUB_SUB_CHANNEL, "mesh:collection:record-change")
27
+ this.subClient.psubscribe("mesh:presence:updates:*", (err) => {
28
+ if (err) {
29
+ this.emitError(new Error(`Failed to subscribe to channels/patterns: ${JSON.stringify({ cause: err })}`))
30
+ reject(err)
31
+ return
32
+ }
33
+ resolve()
34
+ })
35
+ })
36
+ this._setupMessageHandlers()
37
+ return this._subscriptionPromise
38
+ }
39
+
40
+ _setupMessageHandlers() {
41
+ this.subClient.on("message", async (channel, message) => {
42
+ if (channel.startsWith(PUB_SUB_CHANNEL_PREFIX)) {
43
+ this._handleInstancePubSubMessage(channel, message)
44
+ } else if (channel === RECORD_PUB_SUB_CHANNEL) {
45
+ this._handleRecordUpdatePubSubMessage(message)
46
+ } else if (channel === "mesh:collection:record-change") {
47
+ this._handleCollectionRecordChange(message)
48
+ } else {
49
+ const subscribers = this.getChannelSubscriptions(channel)
50
+ if (subscribers) {
51
+ for (const connection of subscribers) {
52
+ if (!connection.isDead) {
53
+ connection.send({ command: "mesh/subscription-message", payload: { channel, message } })
54
+ }
55
+ }
56
+ }
57
+ }
58
+ })
59
+
60
+ this.subClient.on("pmessage", async (pattern, channel, message) => {
61
+ if (pattern === "mesh:presence:updates:*") {
62
+ const subscribers = this.getChannelSubscriptions(channel)
63
+ if (subscribers) {
64
+ try {
65
+ const payload = JSON.parse(message)
66
+ subscribers.forEach((connection) => {
67
+ if (!connection.isDead) {
68
+ connection.send({ command: "mesh/presence-update", payload })
69
+ } else {
70
+ subscribers.delete(connection)
71
+ }
72
+ })
73
+ } catch (e) {
74
+ this.emitError(new Error(`Failed to parse presence update: ${message}`))
75
+ }
76
+ }
77
+ }
78
+ })
79
+ }
80
+
81
+ _handleInstancePubSubMessage(_channel, message) {
82
+ try {
83
+ const parsedMessage = JSON.parse(message)
84
+ if (!parsedMessage || !Array.isArray(parsedMessage.targetConnectionIds) || !parsedMessage.command || typeof parsedMessage.command.command !== "string") {
85
+ throw new Error("Invalid message format")
86
+ }
87
+ const { targetConnectionIds, command } = parsedMessage
88
+ targetConnectionIds.forEach((connectionId) => {
89
+ const connection = this.connectionManager.getLocalConnection(connectionId)
90
+ if (connection && !connection.isDead) connection.send(command)
91
+ })
92
+ } catch (err) {
93
+ this.emitError(new Error(`Failed to parse message: ${message}`))
94
+ }
95
+ }
96
+
97
+ _handleRecordUpdatePubSubMessage(message) {
98
+ try {
99
+ const parsedMessage = JSON.parse(message)
100
+ const { recordId, newValue, patch, version, deleted } = parsedMessage
101
+ if (!recordId || typeof version !== "number") throw new Error("Invalid record update message format")
102
+ const subscribers = this.recordSubscriptions.get(recordId)
103
+ if (!subscribers) return
104
+ subscribers.forEach((mode, connectionId) => {
105
+ const connection = this.connectionManager.getLocalConnection(connectionId)
106
+ if (connection && !connection.isDead) {
107
+ if (deleted) {
108
+ connection.send({ command: "mesh/record-deleted", payload: { recordId, version } })
109
+ } else if (mode === "patch" && patch) {
110
+ connection.send({ command: "mesh/record-update", payload: { recordId, patch, version } })
111
+ } else if (mode === "full" && newValue !== undefined) {
112
+ connection.send({ command: "mesh/record-update", payload: { recordId, full: newValue, version } })
113
+ }
114
+ } else if (!connection || connection.isDead) {
115
+ subscribers.delete(connectionId)
116
+ if (subscribers.size === 0) this.recordSubscriptions.delete(recordId)
117
+ }
118
+ })
119
+ if (deleted) this.recordSubscriptions.delete(recordId)
120
+ } catch (err) {
121
+ this.emitError(new Error(`Failed to parse record update message: ${message}`))
122
+ }
123
+ }
124
+
125
+ async _handleCollectionRecordChange(changedRecordId) {
126
+ if (!this.collectionManager) return
127
+ const collectionSubsMap = this.collectionManager.getCollectionSubscriptions()
128
+ const affectedCollections = new Set()
129
+ for (const [collectionId] of collectionSubsMap.entries()) {
130
+ affectedCollections.add(collectionId)
131
+ }
132
+ for (const collectionId of affectedCollections) {
133
+ const existingTimeout = this.collectionUpdateTimeouts.get(collectionId)
134
+ if (existingTimeout) clearTimeout(existingTimeout)
135
+ if (!this.pendingCollectionUpdates.has(collectionId)) {
136
+ this.pendingCollectionUpdates.set(collectionId, new Set())
137
+ }
138
+ this.pendingCollectionUpdates.get(collectionId).add(changedRecordId)
139
+ const debounceTimeout = setTimeout(async () => {
140
+ await this._processCollectionUpdates(collectionId)
141
+ }, this.COLLECTION_UPDATE_DEBOUNCE_MS)
142
+ this.collectionUpdateTimeouts.set(collectionId, debounceTimeout)
143
+ if (!this.collectionMaxDelayTimeouts.has(collectionId)) {
144
+ const maxDelayTimeout = setTimeout(async () => {
145
+ await this._processCollectionUpdates(collectionId)
146
+ }, this.COLLECTION_MAX_DELAY_MS)
147
+ this.collectionMaxDelayTimeouts.set(collectionId, maxDelayTimeout)
148
+ }
149
+ }
150
+ }
151
+
152
+ async _processCollectionUpdates(collectionId) {
153
+ const changedRecordIds = this.pendingCollectionUpdates.get(collectionId)
154
+ if (!changedRecordIds || changedRecordIds.size === 0) return
155
+ const debounceTimeout = this.collectionUpdateTimeouts.get(collectionId)
156
+ const maxDelayTimeout = this.collectionMaxDelayTimeouts.get(collectionId)
157
+ if (debounceTimeout) { clearTimeout(debounceTimeout); this.collectionUpdateTimeouts.delete(collectionId) }
158
+ if (maxDelayTimeout) { clearTimeout(maxDelayTimeout); this.collectionMaxDelayTimeouts.delete(collectionId) }
159
+ this.pendingCollectionUpdates.delete(collectionId)
160
+ if (!this.collectionManager) return
161
+ const subscribers = this.collectionManager.getCollectionSubscriptions().get(collectionId)
162
+ if (!subscribers || subscribers.size === 0) return
163
+
164
+ for (const [connectionId, { version: currentCollVersion }] of subscribers.entries()) {
165
+ try {
166
+ const connection = this.connectionManager.getLocalConnection(connectionId)
167
+ if (!connection || connection.isDead) continue
168
+
169
+ const newRecords = await this.collectionManager.resolveCollection(collectionId, connection)
170
+ const newRecordIds = newRecords.map((record) => record.id)
171
+ const previousRecordIdsKey = `mesh:collection:${collectionId}:${connectionId}`
172
+ const previousRecordIdsStr = await this.pubClient.get(previousRecordIdsKey)
173
+ const previousRecordIds = previousRecordIdsStr ? JSON.parse(previousRecordIdsStr) : []
174
+
175
+ const addedIds = newRecordIds.filter((id) => !previousRecordIds.includes(id))
176
+ const added = newRecords.filter((record) => addedIds.includes(record.id))
177
+ const removedIds = previousRecordIds.filter((id) => !newRecordIds.includes(id))
178
+
179
+ const removed = []
180
+ for (const removedId of removedIds) {
181
+ try {
182
+ const record = await this.recordManager.getRecord(removedId)
183
+ removed.push(record || { id: removedId })
184
+ } catch { removed.push({ id: removedId }) }
185
+ }
186
+
187
+ const deletedRecords = []
188
+ for (const recordId of changedRecordIds) {
189
+ if (previousRecordIds.includes(recordId) && !newRecordIds.includes(recordId)) {
190
+ deletedRecords.push(recordId)
191
+ }
192
+ }
193
+
194
+ const changeAffectsMembership = added.length > 0 || removed.length > 0
195
+ const deletionAffectsExistingMember = deletedRecords.length > 0
196
+
197
+ if (changeAffectsMembership || deletionAffectsExistingMember) {
198
+ const newCollectionVersion = currentCollVersion + 1
199
+ this.collectionManager.updateSubscriptionVersion(collectionId, connectionId, newCollectionVersion)
200
+ await this.pubClient.set(previousRecordIdsKey, JSON.stringify(newRecordIds))
201
+ connection.send({
202
+ command: "mesh/collection-diff",
203
+ payload: { collectionId, added, removed, version: newCollectionVersion },
204
+ })
205
+ }
206
+
207
+ for (const recordId of changedRecordIds) {
208
+ if (newRecordIds.includes(recordId)) {
209
+ try {
210
+ const { record, version } = await this.recordManager.getRecordAndVersion(recordId)
211
+ if (record) {
212
+ connection.send({ command: "mesh/record-update", payload: { recordId, version, full: record } })
213
+ }
214
+ } catch (recordError) {
215
+ serverLogger.info(`Record ${recordId} not found during collection update (likely deleted).`)
216
+ }
217
+ }
218
+ }
219
+ } catch (connError) {
220
+ this.emitError(new Error(`Error processing collection ${collectionId} for connection ${connectionId}: ${connError}`))
221
+ }
222
+ }
223
+ }
224
+
225
+ getSubscriptionPromise() { return this._subscriptionPromise }
226
+ getPubSubChannel(instanceId) { return `${PUB_SUB_CHANNEL_PREFIX}${instanceId}` }
227
+
228
+ async cleanup() {
229
+ for (const timeout of this.collectionUpdateTimeouts.values()) clearTimeout(timeout)
230
+ this.collectionUpdateTimeouts.clear()
231
+ for (const timeout of this.collectionMaxDelayTimeouts.values()) clearTimeout(timeout)
232
+ this.collectionMaxDelayTimeouts.clear()
233
+ this.pendingCollectionUpdates.clear()
234
+ if (this.subClient && this.subClient.status !== "end") {
235
+ const channel = `${PUB_SUB_CHANNEL_PREFIX}${this.instanceId}`
236
+ await Promise.all([
237
+ new Promise((resolve) => { this.subClient.unsubscribe(channel, RECORD_PUB_SUB_CHANNEL, "mesh:collection:record-change", () => resolve()) }),
238
+ new Promise((resolve) => { this.subClient.punsubscribe("mesh:presence:updates:*", () => resolve()) }),
239
+ ])
240
+ }
241
+ }
242
+ }
@@ -0,0 +1,123 @@
1
+ import { RECORD_PUB_SUB_CHANNEL } from "../utils/constants.js"
2
+
3
+ export class RecordSubscriptionManager {
4
+ constructor({ pubClient, recordManager, emitError, persistenceManager }) {
5
+ this.pubClient = pubClient
6
+ this.recordManager = recordManager
7
+ this.persistenceManager = persistenceManager || null
8
+ this.exposedRecords = []
9
+ this.exposedWritableRecords = []
10
+ this.recordGuards = new Map()
11
+ this.writableRecordGuards = new Map()
12
+ this.recordSubscriptions = new Map()
13
+ this.emitError = emitError
14
+ }
15
+
16
+ setPersistenceManager(persistenceManager) {
17
+ this.persistenceManager = persistenceManager
18
+ }
19
+
20
+ exposeRecord(recordPattern, guard) {
21
+ this.exposedRecords.push(recordPattern)
22
+ if (guard) this.recordGuards.set(recordPattern, guard)
23
+ }
24
+
25
+ exposeWritableRecord(recordPattern, guard) {
26
+ this.exposedWritableRecords.push(recordPattern)
27
+ if (guard) this.writableRecordGuards.set(recordPattern, guard)
28
+ }
29
+
30
+ async isRecordExposed(recordId, connection) {
31
+ const readPattern = this.exposedRecords.find((pattern) =>
32
+ typeof pattern === "string" ? pattern === recordId : pattern.test(recordId)
33
+ )
34
+ let canRead = false
35
+ if (readPattern) {
36
+ const guard = this.recordGuards.get(readPattern)
37
+ if (guard) {
38
+ try { canRead = await Promise.resolve(guard(connection, recordId)) }
39
+ catch { canRead = false }
40
+ } else {
41
+ canRead = true
42
+ }
43
+ }
44
+ if (canRead) return true
45
+ const writePattern = this.exposedWritableRecords.find((pattern) =>
46
+ typeof pattern === "string" ? pattern === recordId : pattern.test(recordId)
47
+ )
48
+ if (writePattern) return true
49
+ return false
50
+ }
51
+
52
+ async isRecordWritable(recordId, connection) {
53
+ const matchedPattern = this.exposedWritableRecords.find((pattern) =>
54
+ typeof pattern === "string" ? pattern === recordId : pattern.test(recordId)
55
+ )
56
+ if (!matchedPattern) return false
57
+ const guard = this.writableRecordGuards.get(matchedPattern)
58
+ if (guard) {
59
+ try { return await Promise.resolve(guard(connection, recordId)) }
60
+ catch { return false }
61
+ }
62
+ return true
63
+ }
64
+
65
+ addSubscription(recordId, connectionId, mode) {
66
+ if (!this.recordSubscriptions.has(recordId)) {
67
+ this.recordSubscriptions.set(recordId, new Map())
68
+ }
69
+ this.recordSubscriptions.get(recordId).set(connectionId, mode)
70
+ }
71
+
72
+ removeSubscription(recordId, connectionId) {
73
+ const recordSubs = this.recordSubscriptions.get(recordId)
74
+ if (recordSubs?.has(connectionId)) {
75
+ recordSubs.delete(connectionId)
76
+ if (recordSubs.size === 0) this.recordSubscriptions.delete(recordId)
77
+ return true
78
+ }
79
+ return false
80
+ }
81
+
82
+ getSubscribers(recordId) {
83
+ return this.recordSubscriptions.get(recordId)
84
+ }
85
+
86
+ async writeRecord(recordId, newValue, options) {
87
+ const updateResult = await this.recordManager.publishUpdate(recordId, newValue, options?.strategy || "replace")
88
+ if (!updateResult) return
89
+ const { patch, version, finalValue } = updateResult
90
+ if (this.persistenceManager) {
91
+ this.persistenceManager.handleRecordUpdate(recordId, finalValue, version)
92
+ }
93
+ const messagePayload = { recordId, newValue: finalValue, patch, version }
94
+ try {
95
+ await this.pubClient.publish(RECORD_PUB_SUB_CHANNEL, JSON.stringify(messagePayload))
96
+ } catch (err) {
97
+ this.emitError(new Error(`Failed to publish record update for "${recordId}": ${err}`))
98
+ }
99
+ }
100
+
101
+ cleanupConnection(connection) {
102
+ const connectionId = connection.id
103
+ this.recordSubscriptions.forEach((subscribers, recordId) => {
104
+ if (subscribers.has(connectionId)) {
105
+ subscribers.delete(connectionId)
106
+ if (subscribers.size === 0) this.recordSubscriptions.delete(recordId)
107
+ }
108
+ })
109
+ }
110
+
111
+ async publishRecordDeletion(recordId, version) {
112
+ const messagePayload = { recordId, deleted: true, version }
113
+ try {
114
+ await this.pubClient.publish(RECORD_PUB_SUB_CHANNEL, JSON.stringify(messagePayload))
115
+ } catch (err) {
116
+ this.emitError(new Error(`Failed to publish record deletion for "${recordId}": ${err}`))
117
+ }
118
+ }
119
+
120
+ getRecordSubscriptions() {
121
+ return this.recordSubscriptions
122
+ }
123
+ }
@@ -0,0 +1,110 @@
1
+ import jsonpatch from "fast-json-patch"
2
+ import { deepMerge, isObject, serverLogger } from "../../shared/index.js"
3
+ import { RECORD_KEY_PREFIX, RECORD_VERSION_KEY_PREFIX } from "../utils/constants.js"
4
+
5
+ export class RecordManager {
6
+ constructor({ redis, server }) {
7
+ this.redis = redis
8
+ this.server = server
9
+ this.recordUpdateCallbacks = []
10
+ this.recordRemovedCallbacks = []
11
+ }
12
+
13
+ getServer() { return this.server }
14
+ getRedis() { return this.redis }
15
+
16
+ recordKey(recordId) { return `${RECORD_KEY_PREFIX}${recordId}` }
17
+ recordVersionKey(recordId) { return `${RECORD_VERSION_KEY_PREFIX}${recordId}` }
18
+
19
+ async getRecord(recordId) {
20
+ const data = await this.redis.get(this.recordKey(recordId))
21
+ return data ? JSON.parse(data) : null
22
+ }
23
+
24
+ async getVersion(recordId) {
25
+ const version = await this.redis.get(this.recordVersionKey(recordId))
26
+ return version ? parseInt(version, 10) : 0
27
+ }
28
+
29
+ async getRecordAndVersion(recordId) {
30
+ const pipeline = this.redis.pipeline()
31
+ pipeline.get(this.recordKey(recordId))
32
+ pipeline.get(this.recordVersionKey(recordId))
33
+ const results = await pipeline.exec()
34
+ const recordData = results?.[0]?.[1]
35
+ const versionData = results?.[1]?.[1]
36
+ const record = recordData ? JSON.parse(recordData) : null
37
+ const version = versionData ? parseInt(versionData, 10) : 0
38
+ return { record, version }
39
+ }
40
+
41
+ async publishUpdate(recordId, newValue, strategy = "replace") {
42
+ const recordKey = this.recordKey(recordId)
43
+ const versionKey = this.recordVersionKey(recordId)
44
+ const { record: oldValue, version: oldVersion } = await this.getRecordAndVersion(recordId)
45
+
46
+ let finalValue
47
+ if (strategy === "merge") {
48
+ finalValue = isObject(oldValue) && isObject(newValue) ? { ...oldValue, ...newValue } : newValue
49
+ } else if (strategy === "deepMerge") {
50
+ finalValue = isObject(oldValue) && isObject(newValue) ? deepMerge(oldValue, newValue) : newValue
51
+ } else {
52
+ finalValue = newValue
53
+ }
54
+
55
+ const patch = jsonpatch.compare(oldValue ?? {}, finalValue ?? {})
56
+ if (patch.length === 0) return null
57
+
58
+ const newVersion = oldVersion + 1
59
+ const pipeline = this.redis.pipeline()
60
+ pipeline.set(recordKey, JSON.stringify(finalValue))
61
+ pipeline.set(versionKey, newVersion.toString())
62
+ await pipeline.exec()
63
+
64
+ if (this.recordUpdateCallbacks.length > 0) {
65
+ Promise.all(
66
+ this.recordUpdateCallbacks.map(async (callback) => {
67
+ try { await callback({ recordId, value: finalValue }) }
68
+ catch (error) { serverLogger.error(`Error in record update callback for ${recordId}:`, error) }
69
+ })
70
+ ).catch((error) => {
71
+ serverLogger.error(`Error in record update callbacks for ${recordId}:`, error)
72
+ })
73
+ }
74
+
75
+ return { patch, version: newVersion, finalValue }
76
+ }
77
+
78
+ async deleteRecord(recordId) {
79
+ const { record, version } = await this.getRecordAndVersion(recordId)
80
+ if (!record) return null
81
+
82
+ const pipeline = this.redis.pipeline()
83
+ pipeline.del(this.recordKey(recordId))
84
+ pipeline.del(this.recordVersionKey(recordId))
85
+ await pipeline.exec()
86
+
87
+ if (this.recordRemovedCallbacks.length > 0) {
88
+ Promise.all(
89
+ this.recordRemovedCallbacks.map(async (callback) => {
90
+ try { await callback({ recordId, value: record }) }
91
+ catch (error) { serverLogger.error(`Error in record removed callback for ${recordId}:`, error) }
92
+ })
93
+ ).catch((error) => {
94
+ serverLogger.error(`Error in record removed callbacks for ${recordId}:`, error)
95
+ })
96
+ }
97
+
98
+ return { version }
99
+ }
100
+
101
+ onRecordUpdate(callback) {
102
+ this.recordUpdateCallbacks.push(callback)
103
+ return () => { this.recordUpdateCallbacks = this.recordUpdateCallbacks.filter((cb) => cb !== callback) }
104
+ }
105
+
106
+ onRecordRemoved(callback) {
107
+ this.recordRemovedCallbacks.push(callback)
108
+ return () => { this.recordRemovedCallbacks = this.recordRemovedCallbacks.filter((cb) => cb !== callback) }
109
+ }
110
+ }
@@ -0,0 +1,61 @@
1
+ import { Redis } from "ioredis"
2
+
3
+ export class RedisManager {
4
+ _redis = null
5
+ _pubClient = null
6
+ _subClient = null
7
+ _isShuttingDown = false
8
+
9
+ initialize(options, onError) {
10
+ this._onRedisConnect = null
11
+ this._onRedisDisconnect = null
12
+
13
+ this._redis = new Redis({
14
+ retryStrategy: (times) => {
15
+ if (this._isShuttingDown) return null
16
+ if (times > 10) return null
17
+ return Math.min(1000 * Math.pow(2, times), 30000)
18
+ },
19
+ ...options,
20
+ })
21
+ this._redis.on("error", (err) => onError(new Error(`Redis error: ${err}`)))
22
+ this._redis.on("connect", () => { if (this._onRedisConnect) this._onRedisConnect() })
23
+ this._redis.on("close", () => { if (!this._isShuttingDown && this._onRedisDisconnect) this._onRedisDisconnect() })
24
+ this._pubClient = this._redis.duplicate()
25
+ this._subClient = this._redis.duplicate()
26
+ }
27
+
28
+ get redis() {
29
+ if (!this._redis) throw new Error("Redis not initialized")
30
+ return this._redis
31
+ }
32
+
33
+ get pubClient() {
34
+ if (!this._pubClient) throw new Error("Redis pub client not initialized")
35
+ return this._pubClient
36
+ }
37
+
38
+ get subClient() {
39
+ if (!this._subClient) throw new Error("Redis sub client not initialized")
40
+ return this._subClient
41
+ }
42
+
43
+ disconnect() {
44
+ this._isShuttingDown = true
45
+ if (this._pubClient) { this._pubClient.disconnect(); this._pubClient = null }
46
+ if (this._subClient) { this._subClient.disconnect(); this._subClient = null }
47
+ if (this._redis) { this._redis.disconnect(); this._redis = null }
48
+ }
49
+
50
+ get isShuttingDown() { return this._isShuttingDown }
51
+ set isShuttingDown(value) { this._isShuttingDown = value }
52
+
53
+ async enableKeyspaceNotifications() {
54
+ const result = await this.redis.config("GET", "notify-keyspace-events")
55
+ const currentConfig = Array.isArray(result) && result.length > 1 ? result[1] : ""
56
+ let newConfig = currentConfig || ""
57
+ if (!newConfig.includes("E")) newConfig += "E"
58
+ if (!newConfig.includes("x")) newConfig += "x"
59
+ await this.redis.config("SET", "notify-keyspace-events", newConfig)
60
+ }
61
+ }
@@ -0,0 +1,129 @@
1
+ import { deepMerge, isObject, serverLogger } from "../../shared/index.js"
2
+
3
+ export class RoomManager {
4
+ constructor({ redis }) {
5
+ this.redis = redis
6
+ }
7
+
8
+ roomKey(roomName) { return `mesh:room:${roomName}` }
9
+ connectionsRoomKey(connectionId) { return `mesh:connection:${connectionId}:rooms` }
10
+ roomMetadataKey(roomName) { return `mesh:roommeta:${roomName}` }
11
+
12
+ async getRoomConnectionIds(roomName) {
13
+ return this.redis.smembers(this.roomKey(roomName))
14
+ }
15
+
16
+ async connectionIsInRoom(roomName, connection) {
17
+ const connectionId = typeof connection === "string" ? connection : connection.id
18
+ return !!(await this.redis.sismember(this.roomKey(roomName), connectionId))
19
+ }
20
+
21
+ async addToRoom(roomName, connection) {
22
+ const connectionId = typeof connection === "string" ? connection : connection.id
23
+ await this.redis.sadd(this.roomKey(roomName), connectionId)
24
+ await this.redis.sadd(this.connectionsRoomKey(connectionId), roomName)
25
+ }
26
+
27
+ async getRoomsForConnection(connection) {
28
+ const connectionId = typeof connection === "string" ? connection : connection.id
29
+ return await this.redis.smembers(this.connectionsRoomKey(connectionId))
30
+ }
31
+
32
+ async getAllRooms() {
33
+ const keys = await this.redis.keys("mesh:room:*")
34
+ return keys.map((key) => key.replace("mesh:room:", ""))
35
+ }
36
+
37
+ async removeFromRoom(roomName, connection) {
38
+ const connectionId = typeof connection === "string" ? connection : connection.id
39
+ const pipeline = this.redis.pipeline()
40
+ pipeline.srem(this.roomKey(roomName), connectionId)
41
+ pipeline.srem(this.connectionsRoomKey(connectionId), roomName)
42
+ await pipeline.exec()
43
+ }
44
+
45
+ async removeFromAllRooms(connection) {
46
+ const connectionId = typeof connection === "string" ? connection : connection.id
47
+ const rooms = await this.redis.smembers(this.connectionsRoomKey(connectionId))
48
+ const pipeline = this.redis.pipeline()
49
+ for (const room of rooms) {
50
+ pipeline.srem(this.roomKey(room), connectionId)
51
+ }
52
+ pipeline.del(this.connectionsRoomKey(connectionId))
53
+ await pipeline.exec()
54
+ }
55
+
56
+ async clearRoom(roomName) {
57
+ const connectionIds = await this.getRoomConnectionIds(roomName)
58
+ const pipeline = this.redis.pipeline()
59
+ for (const connectionId of connectionIds) {
60
+ pipeline.srem(this.connectionsRoomKey(connectionId), roomName)
61
+ }
62
+ pipeline.del(this.roomKey(roomName))
63
+ await pipeline.exec()
64
+ }
65
+
66
+ async deleteRoom(roomName) {
67
+ const connectionIds = await this.getRoomConnectionIds(roomName)
68
+ const pipeline = this.redis.pipeline()
69
+ for (const connectionId of connectionIds) {
70
+ pipeline.srem(this.connectionsRoomKey(connectionId), roomName)
71
+ }
72
+ pipeline.del(this.roomKey(roomName))
73
+ pipeline.del(this.roomMetadataKey(roomName))
74
+ await pipeline.exec()
75
+ }
76
+
77
+ async cleanupConnection(connection) {
78
+ const rooms = await this.redis.smembers(this.connectionsRoomKey(connection.id))
79
+ const pipeline = this.redis.pipeline()
80
+ for (const room of rooms) {
81
+ pipeline.srem(this.roomKey(room), connection.id)
82
+ }
83
+ pipeline.del(this.connectionsRoomKey(connection.id))
84
+ await pipeline.exec()
85
+ }
86
+
87
+ async setMetadata(roomName, metadata, options) {
88
+ let finalMetadata
89
+ const strategy = options?.strategy || "replace"
90
+ if (strategy === "replace") {
91
+ finalMetadata = metadata
92
+ } else {
93
+ const existingMetadata = await this.getMetadata(roomName)
94
+ if (strategy === "merge") {
95
+ finalMetadata = isObject(existingMetadata) && isObject(metadata)
96
+ ? { ...existingMetadata, ...metadata }
97
+ : metadata
98
+ } else if (strategy === "deepMerge") {
99
+ finalMetadata = isObject(existingMetadata) && isObject(metadata)
100
+ ? deepMerge(existingMetadata, metadata)
101
+ : metadata
102
+ }
103
+ }
104
+ await this.redis.hset(this.roomMetadataKey(roomName), "data", JSON.stringify(finalMetadata))
105
+ }
106
+
107
+ async getMetadata(roomName) {
108
+ const data = await this.redis.hget(this.roomMetadataKey(roomName), "data")
109
+ return data ? JSON.parse(data) : null
110
+ }
111
+
112
+ async getAllMetadata() {
113
+ const keys = await this.redis.keys("mesh:roommeta:*")
114
+ const result = []
115
+ if (keys.length === 0) return result
116
+ const pipeline = this.redis.pipeline()
117
+ keys.forEach((key) => pipeline.hget(key, "data"))
118
+ const results = await pipeline.exec()
119
+ keys.forEach((key, index) => {
120
+ const roomName = key.replace("mesh:roommeta:", "")
121
+ const data = results?.[index]?.[1]
122
+ if (data) {
123
+ try { result.push({ id: roomName, metadata: JSON.parse(data) }) }
124
+ catch (e) { serverLogger.error(`Failed to parse metadata for room ${roomName}:`, e) }
125
+ }
126
+ })
127
+ return result
128
+ }
129
+ }
@@ -0,0 +1,20 @@
1
+ import { EventEmitter } from "node:events"
2
+
3
+ export class MessageStream extends EventEmitter {
4
+ constructor() {
5
+ super()
6
+ this.setMaxListeners(100)
7
+ }
8
+
9
+ publishMessage(channel, message, instanceId) {
10
+ this.emit("message", { channel, message, instanceId, timestamp: Date.now() })
11
+ }
12
+
13
+ subscribeToMessages(callback) {
14
+ this.on("message", callback)
15
+ }
16
+
17
+ unsubscribeFromMessages(callback) {
18
+ this.off("message", callback)
19
+ }
20
+ }