@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,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
|
+
}
|