@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.
- package/package.json +49 -0
- package/src/adapters/postgres.js +139 -0
- package/src/adapters/sqlite.js +177 -0
- package/src/client/client.js +441 -0
- package/src/client/connection.js +178 -0
- package/src/client/ids.js +26 -0
- package/src/client/index.js +5 -0
- package/src/client/queue.js +44 -0
- package/src/client/subscriptions/channels.js +79 -0
- package/src/client/subscriptions/collections.js +96 -0
- package/src/client/subscriptions/presence.js +98 -0
- package/src/client/subscriptions/records.js +123 -0
- package/src/client/subscriptions/rooms.js +69 -0
- package/src/devtools/client/dist/assets/index-CGm1NqOQ.css +1 -0
- package/src/devtools/client/dist/assets/index-w2FI7RvC.js +168 -0
- package/src/devtools/client/dist/index.html +16 -0
- package/src/devtools/client/index.html +15 -0
- package/src/devtools/client/package.json +17 -0
- package/src/devtools/client/src/App.vue +173 -0
- package/src/devtools/client/src/components/ConnectionPicker.vue +38 -0
- package/src/devtools/client/src/components/JsonView.vue +18 -0
- package/src/devtools/client/src/composables/useApi.js +71 -0
- package/src/devtools/client/src/composables/useHighlight.js +57 -0
- package/src/devtools/client/src/main.js +5 -0
- package/src/devtools/client/src/style.css +440 -0
- package/src/devtools/client/src/views/ChannelsView.vue +27 -0
- package/src/devtools/client/src/views/CollectionsView.vue +61 -0
- package/src/devtools/client/src/views/MetadataView.vue +108 -0
- package/src/devtools/client/src/views/RecordsView.vue +30 -0
- package/src/devtools/client/src/views/RoomsView.vue +39 -0
- package/src/devtools/client/vite.config.js +17 -0
- package/src/devtools/demo/server.js +144 -0
- package/src/devtools/index.js +186 -0
- package/src/index.js +9 -0
- package/src/server/connection.js +116 -0
- package/src/server/context.js +22 -0
- package/src/server/managers/broadcast.js +94 -0
- package/src/server/managers/channels.js +118 -0
- package/src/server/managers/collections.js +127 -0
- package/src/server/managers/commands.js +55 -0
- package/src/server/managers/connections.js +111 -0
- package/src/server/managers/instance.js +125 -0
- package/src/server/managers/persistence.js +371 -0
- package/src/server/managers/presence.js +217 -0
- package/src/server/managers/pubsub.js +242 -0
- package/src/server/managers/record-subscriptions.js +123 -0
- package/src/server/managers/records.js +110 -0
- package/src/server/managers/redis.js +61 -0
- package/src/server/managers/rooms.js +129 -0
- package/src/server/message-stream.js +20 -0
- package/src/server/server.js +878 -0
- package/src/server/utils/constants.js +4 -0
- package/src/server/utils/ids.js +5 -0
- package/src/server/utils/pattern-conversion.js +14 -0
- package/src/shared/errors.js +7 -0
- package/src/shared/index.js +5 -0
- package/src/shared/logger.js +53 -0
- package/src/shared/merge.js +17 -0
- package/src/shared/message.js +17 -0
- 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
|
+
}
|