@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,878 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from "node:http"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { WebSocketServer } from "ws"
|
|
4
|
+
import { LogLevel, Status, serverLogger, parseCommand } from "../shared/index.js"
|
|
5
|
+
import { Connection } from "./connection.js"
|
|
6
|
+
import { PUB_SUB_CHANNEL_PREFIX } from "./utils/constants.js"
|
|
7
|
+
import { ConnectionManager } from "./managers/connections.js"
|
|
8
|
+
import { PresenceManager } from "./managers/presence.js"
|
|
9
|
+
import { RecordManager } from "./managers/records.js"
|
|
10
|
+
import { RoomManager } from "./managers/rooms.js"
|
|
11
|
+
import { BroadcastManager } from "./managers/broadcast.js"
|
|
12
|
+
import { ChannelManager } from "./managers/channels.js"
|
|
13
|
+
import { CommandManager } from "./managers/commands.js"
|
|
14
|
+
import { PubSubManager } from "./managers/pubsub.js"
|
|
15
|
+
import { RecordSubscriptionManager } from "./managers/record-subscriptions.js"
|
|
16
|
+
import { RedisManager } from "./managers/redis.js"
|
|
17
|
+
import { InstanceManager } from "./managers/instance.js"
|
|
18
|
+
import { CollectionManager } from "./managers/collections.js"
|
|
19
|
+
import { PersistenceManager } from "./managers/persistence.js"
|
|
20
|
+
import { MessageStream } from "./message-stream.js"
|
|
21
|
+
|
|
22
|
+
const pendingAuthDataStore = new WeakMap()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} RealtimeServerOptions
|
|
26
|
+
* @property {{host?: string, port?: number, db?: number}} redis
|
|
27
|
+
* @property {import('./managers/persistence.js').PersistenceAdapter} [persistence]
|
|
28
|
+
* @property {(req: import('node:http').IncomingMessage) => Promise<any> | any} [authenticateConnection]
|
|
29
|
+
* @property {number} [pingInterval]
|
|
30
|
+
* @property {number} [latencyInterval]
|
|
31
|
+
* @property {number} [maxMissedPongs]
|
|
32
|
+
* @property {number} [logLevel]
|
|
33
|
+
* @property {boolean} [enablePresenceExpirationEvents]
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** @typedef {string | RegExp} ChannelPattern */
|
|
37
|
+
|
|
38
|
+
export class RealtimeServer {
|
|
39
|
+
/** @param {RealtimeServerOptions} opts */
|
|
40
|
+
constructor(opts = {}) {
|
|
41
|
+
this.instanceId = randomUUID()
|
|
42
|
+
this.status = Status.OFFLINE
|
|
43
|
+
this._listening = false
|
|
44
|
+
this._wss = null
|
|
45
|
+
this._httpServer = null
|
|
46
|
+
this._authenticateConnection = opts.authenticateConnection
|
|
47
|
+
|
|
48
|
+
this.serverOptions = {
|
|
49
|
+
...opts,
|
|
50
|
+
pingInterval: opts.pingInterval ?? 30_000,
|
|
51
|
+
latencyInterval: opts.latencyInterval ?? 5_000,
|
|
52
|
+
maxMissedPongs: opts.maxMissedPongs ?? 1,
|
|
53
|
+
logLevel: opts.logLevel ?? LogLevel.ERROR,
|
|
54
|
+
enablePresenceExpirationEvents: opts.enablePresenceExpirationEvents ?? true,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
serverLogger.configure({ level: this.serverOptions.logLevel, styling: false })
|
|
58
|
+
|
|
59
|
+
this.redisManager = new RedisManager()
|
|
60
|
+
this.redisManager.initialize(opts.redis, (err) => this._emitError(err))
|
|
61
|
+
|
|
62
|
+
this.instanceManager = new InstanceManager({ redis: this.redisManager.redis, instanceId: this.instanceId })
|
|
63
|
+
|
|
64
|
+
this.roomManager = new RoomManager({ redis: this.redisManager.redis })
|
|
65
|
+
this.recordManager = new RecordManager({ redis: this.redisManager.redis, server: this })
|
|
66
|
+
this.connectionManager = new ConnectionManager({ redis: this.redisManager.pubClient, instanceId: this.instanceId, roomManager: this.roomManager })
|
|
67
|
+
this.presenceManager = new PresenceManager({
|
|
68
|
+
redis: this.redisManager.redis,
|
|
69
|
+
roomManager: this.roomManager,
|
|
70
|
+
redisManager: this.redisManager,
|
|
71
|
+
enableExpirationEvents: this.serverOptions.enablePresenceExpirationEvents,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
if (this.serverOptions.enablePresenceExpirationEvents) {
|
|
75
|
+
this.redisManager.enableKeyspaceNotifications().catch((err) => this._emitError(new Error(`Failed to enable keyspace notifications: ${err}`)))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.commandManager = new CommandManager()
|
|
79
|
+
this.messageStream = new MessageStream()
|
|
80
|
+
|
|
81
|
+
this.persistenceManager = opts.persistence
|
|
82
|
+
? new PersistenceManager({ adapter: opts.persistence })
|
|
83
|
+
: null
|
|
84
|
+
|
|
85
|
+
if (this.persistenceManager) {
|
|
86
|
+
this.persistenceManager.setMessageStream(this.messageStream)
|
|
87
|
+
this.persistenceManager.setRecordManager(this.recordManager)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.channelManager = new ChannelManager({
|
|
91
|
+
redis: this.redisManager.redis,
|
|
92
|
+
pubClient: this.redisManager.pubClient,
|
|
93
|
+
subClient: this.redisManager.subClient,
|
|
94
|
+
messageStream: this.messageStream,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
if (this.persistenceManager) {
|
|
98
|
+
this.channelManager.setPersistenceManager(this.persistenceManager)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.recordSubscriptionManager = new RecordSubscriptionManager({
|
|
102
|
+
pubClient: this.redisManager.pubClient,
|
|
103
|
+
recordManager: this.recordManager,
|
|
104
|
+
emitError: (err) => this._emitError(err),
|
|
105
|
+
persistenceManager: this.persistenceManager,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
this.collectionManager = new CollectionManager({ redis: this.redisManager.redis, emitError: (err) => this._emitError(err) })
|
|
109
|
+
|
|
110
|
+
this.recordManager.onRecordUpdate(async ({ recordId }) => {
|
|
111
|
+
try { await this.collectionManager.publishRecordChange(recordId) }
|
|
112
|
+
catch (error) { this._emitError(new Error(`Failed to publish record update for collection check: ${error}`)) }
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
this.recordManager.onRecordRemoved(async ({ recordId }) => {
|
|
116
|
+
try { await this.collectionManager.publishRecordChange(recordId) }
|
|
117
|
+
catch (error) { this._emitError(new Error(`Failed to publish record removal for collection check: ${error}`)) }
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
this.pubSubManager = new PubSubManager({
|
|
121
|
+
subClient: this.redisManager.subClient,
|
|
122
|
+
pubClient: this.redisManager.pubClient,
|
|
123
|
+
instanceId: this.instanceId,
|
|
124
|
+
connectionManager: this.connectionManager,
|
|
125
|
+
recordManager: this.recordManager,
|
|
126
|
+
recordSubscriptions: this.recordSubscriptionManager.getRecordSubscriptions(),
|
|
127
|
+
getChannelSubscriptions: this.channelManager.getSubscribers.bind(this.channelManager),
|
|
128
|
+
emitError: (err) => this._emitError(err),
|
|
129
|
+
collectionManager: this.collectionManager,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
this.broadcastManager = new BroadcastManager({
|
|
133
|
+
connectionManager: this.connectionManager,
|
|
134
|
+
roomManager: this.roomManager,
|
|
135
|
+
instanceId: this.instanceId,
|
|
136
|
+
pubClient: this.redisManager.pubClient,
|
|
137
|
+
getPubSubChannel: (instanceId) => `${PUB_SUB_CHANNEL_PREFIX}${instanceId}`,
|
|
138
|
+
emitError: (err) => this._emitError(err),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
this._errorHandlers = []
|
|
142
|
+
this._connectedHandlers = []
|
|
143
|
+
this._disconnectedHandlers = []
|
|
144
|
+
|
|
145
|
+
this._registerBuiltinCommands()
|
|
146
|
+
this._registerRecordCommands()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @returns {number} */
|
|
150
|
+
get connectionCount() {
|
|
151
|
+
return this.connectionManager.getLocalConnections().length
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @returns {Promise<number>} */
|
|
155
|
+
async totalConnectionCount() {
|
|
156
|
+
const ids = await this.connectionManager.getAllConnectionIds()
|
|
157
|
+
return ids.length
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
enableGracefulShutdown() {
|
|
161
|
+
const handler = () => {
|
|
162
|
+
serverLogger.info("Received shutdown signal, closing...")
|
|
163
|
+
this.close().then(() => process.exit(0))
|
|
164
|
+
}
|
|
165
|
+
process.on("SIGTERM", handler)
|
|
166
|
+
process.on("SIGINT", handler)
|
|
167
|
+
return this
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
get port() {
|
|
171
|
+
const address = this._wss?.address()
|
|
172
|
+
return address?.port
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
get listening() {
|
|
176
|
+
return this._listening
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_emitError(err) {
|
|
180
|
+
serverLogger.error(`Error: ${err}`)
|
|
181
|
+
for (const handler of this._errorHandlers) handler(err)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** @param {() => void} handler @returns {this} */
|
|
185
|
+
onRedisConnect(handler) { this.redisManager._onRedisConnect = handler; return this }
|
|
186
|
+
/** @param {() => void} handler @returns {this} */
|
|
187
|
+
onRedisDisconnect(handler) { this.redisManager._onRedisDisconnect = handler; return this }
|
|
188
|
+
/** @param {(err: Error) => void} handler @returns {this} */
|
|
189
|
+
onError(handler) { this._errorHandlers.push(handler); return this }
|
|
190
|
+
/** @param {(connection: Connection) => void | Promise<void>} handler @returns {this} */
|
|
191
|
+
onConnection(handler) { this._connectedHandlers.push(handler); return this }
|
|
192
|
+
/** @param {(connection: Connection) => void | Promise<void>} handler @returns {this} */
|
|
193
|
+
onDisconnection(handler) { this._disconnectedHandlers.push(handler); return this }
|
|
194
|
+
/** @param {(data: {recordId: string, value: any}) => void | Promise<void>} callback @returns {() => void} */
|
|
195
|
+
onRecordUpdate(callback) { return this.recordManager.onRecordUpdate(callback) }
|
|
196
|
+
/** @param {(data: {recordId: string, value: any}) => void | Promise<void>} callback @returns {() => void} */
|
|
197
|
+
onRecordRemoved(callback) { return this.recordManager.onRecordRemoved(callback) }
|
|
198
|
+
|
|
199
|
+
/** @param {number} port @returns {Promise<void>} */
|
|
200
|
+
async listen(port) {
|
|
201
|
+
const httpServer = createHttpServer()
|
|
202
|
+
this._httpServer = httpServer
|
|
203
|
+
this._ownsHttpServer = true
|
|
204
|
+
await this._startWithServer(httpServer, port)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @param {import('node:http').Server} httpServer
|
|
209
|
+
* @param {{port?: number}} [options]
|
|
210
|
+
* @returns {Promise<void>}
|
|
211
|
+
*/
|
|
212
|
+
async attach(httpServer, { port } = {}) {
|
|
213
|
+
this._httpServer = httpServer
|
|
214
|
+
this._ownsHttpServer = false
|
|
215
|
+
const isListening = httpServer.listening
|
|
216
|
+
if (!isListening && port !== undefined) {
|
|
217
|
+
await new Promise((resolve) => { httpServer.listen(port, resolve) })
|
|
218
|
+
} else if (!isListening) {
|
|
219
|
+
await new Promise((resolve) => { httpServer.listen(resolve) })
|
|
220
|
+
}
|
|
221
|
+
await this._startWithServer(httpServer)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async _startWithServer(httpServer, port) {
|
|
225
|
+
const wsOpts = { server: httpServer }
|
|
226
|
+
|
|
227
|
+
if (this._authenticateConnection) {
|
|
228
|
+
wsOpts.verifyClient = (info, cb) => {
|
|
229
|
+
Promise.resolve()
|
|
230
|
+
.then(() => this._authenticateConnection(info.req))
|
|
231
|
+
.then((authData) => {
|
|
232
|
+
if (authData != null) {
|
|
233
|
+
pendingAuthDataStore.set(info.req, authData)
|
|
234
|
+
cb(true)
|
|
235
|
+
} else {
|
|
236
|
+
cb(false, 401, "Unauthorized")
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
.catch((err) => {
|
|
240
|
+
const code = err?.code ?? 401
|
|
241
|
+
const message = err?.message ?? "Unauthorized"
|
|
242
|
+
cb(false, code, message)
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
this._wss = new WebSocketServer(wsOpts)
|
|
248
|
+
|
|
249
|
+
if (port !== undefined && !httpServer.listening) {
|
|
250
|
+
await new Promise((resolve) => { httpServer.listen(port, resolve) })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
this._applyListeners()
|
|
254
|
+
|
|
255
|
+
this.pubSubManager.subscribeToInstanceChannel()
|
|
256
|
+
|
|
257
|
+
const persistencePromise = this.persistenceManager
|
|
258
|
+
? this.persistenceManager.initialize().then(() => this.persistenceManager.restorePersistedRecords())
|
|
259
|
+
: Promise.resolve()
|
|
260
|
+
|
|
261
|
+
await Promise.all([
|
|
262
|
+
this.pubSubManager.getSubscriptionPromise(),
|
|
263
|
+
persistencePromise,
|
|
264
|
+
])
|
|
265
|
+
|
|
266
|
+
await this.instanceManager.start()
|
|
267
|
+
|
|
268
|
+
this._listening = true
|
|
269
|
+
this.status = Status.ONLINE
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_applyListeners() {
|
|
273
|
+
this._wss.on("connection", async (socket, req) => {
|
|
274
|
+
const connection = new Connection(socket, req, this.serverOptions, this)
|
|
275
|
+
|
|
276
|
+
connection.on("message", (buffer) => {
|
|
277
|
+
try {
|
|
278
|
+
const data = buffer.toString()
|
|
279
|
+
const command = parseCommand(data)
|
|
280
|
+
if (command.id !== undefined && !["latency:response", "pong"].includes(command.command)) {
|
|
281
|
+
this.commandManager.runCommand(command.id, command.command, command.payload, connection, this)
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
this._emitError(err)
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
await this.connectionManager.registerConnection(connection)
|
|
290
|
+
const authData = pendingAuthDataStore.get(req)
|
|
291
|
+
if (authData) {
|
|
292
|
+
pendingAuthDataStore.delete(req)
|
|
293
|
+
await this.connectionManager.setMetadata(connection, authData)
|
|
294
|
+
}
|
|
295
|
+
connection.send({ command: "mesh/assign-id", payload: connection.id })
|
|
296
|
+
} catch (error) {
|
|
297
|
+
connection.close()
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const handler of this._connectedHandlers) handler(connection)
|
|
302
|
+
|
|
303
|
+
connection.on("close", async () => {
|
|
304
|
+
await this.cleanupConnection(connection)
|
|
305
|
+
for (const handler of this._disconnectedHandlers) handler(connection)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
connection.on("error", (err) => {
|
|
309
|
+
this._emitError(err)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
connection.on("pong", async (connectionId) => {
|
|
313
|
+
try {
|
|
314
|
+
const rooms = await this.roomManager.getRoomsForConnection(connectionId)
|
|
315
|
+
for (const roomName of rooms) {
|
|
316
|
+
if (await this.presenceManager.isRoomTracked(roomName)) {
|
|
317
|
+
await this.presenceManager.refreshPresence(connectionId, roomName)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
this._emitError(new Error(`Failed to refresh presence: ${err}`))
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* @param {string} command
|
|
329
|
+
* @param {(ctx: import('./context.js').Context) => any | Promise<any>} callback
|
|
330
|
+
* @param {Array<(ctx: import('./context.js').Context) => any | Promise<any>>} [middlewares]
|
|
331
|
+
*/
|
|
332
|
+
exposeCommand(command, callback, middlewares = []) {
|
|
333
|
+
this.commandManager.exposeCommand(command, callback, middlewares)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** @param {...(ctx: import('./context.js').Context) => any | Promise<any>} middlewares */
|
|
337
|
+
useMiddleware(...middlewares) {
|
|
338
|
+
this.commandManager.useMiddleware(...middlewares)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @param {ChannelPattern} channel
|
|
344
|
+
* @param {(connection: Connection, channel: string) => boolean | Promise<boolean>} [guard]
|
|
345
|
+
*/
|
|
346
|
+
exposeChannel(channel, guard) {
|
|
347
|
+
this.channelManager.exposeChannel(channel, guard)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @param {string} channel
|
|
352
|
+
* @param {any} message - auto-stringified if not a string
|
|
353
|
+
* @param {number} [history] - number of messages to retain in redis history
|
|
354
|
+
* @returns {Promise<void>}
|
|
355
|
+
*/
|
|
356
|
+
async writeChannel(channel, message, history = 0) {
|
|
357
|
+
return this.channelManager.writeChannel(channel, message, history, this.instanceId)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {ChannelPattern} pattern
|
|
362
|
+
* @param {{historyLimit?: number, filter?: (message: string, channel: string) => boolean, flushInterval?: number, maxBufferSize?: number}} [options]
|
|
363
|
+
*/
|
|
364
|
+
enableChannelPersistence(pattern, options = {}) {
|
|
365
|
+
if (!this.persistenceManager) throw new Error("Persistence not enabled. Pass a persistence adapter in options.")
|
|
366
|
+
this.persistenceManager.enableChannelPersistence(pattern, options)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @param {{pattern: ChannelPattern, adapter?: {adapter?: any, restorePattern: string}, hooks?: {persist: (records: Array<{recordId: string, value: any, version: number}>) => Promise<void>, restore: () => Promise<Array<{recordId: string, value: any, version: number}>>}, flushInterval?: number, maxBufferSize?: number}} config
|
|
371
|
+
*/
|
|
372
|
+
enableRecordPersistence(config) {
|
|
373
|
+
if (!this.persistenceManager) throw new Error("Persistence not enabled. Pass a persistence adapter in options.")
|
|
374
|
+
this.persistenceManager.enableRecordPersistence(config)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @param {ChannelPattern} recordPattern
|
|
379
|
+
* @param {(connection: Connection, recordId: string) => boolean | Promise<boolean>} [guard]
|
|
380
|
+
*/
|
|
381
|
+
exposeRecord(recordPattern, guard) {
|
|
382
|
+
this.recordSubscriptionManager.exposeRecord(recordPattern, guard)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* @param {ChannelPattern} recordPattern
|
|
387
|
+
* @param {(connection: Connection, recordId: string) => boolean | Promise<boolean>} [guard]
|
|
388
|
+
*/
|
|
389
|
+
exposeWritableRecord(recordPattern, guard) {
|
|
390
|
+
this.recordSubscriptionManager.exposeWritableRecord(recordPattern, guard)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* @param {string} recordId
|
|
395
|
+
* @param {any} newValue
|
|
396
|
+
* @param {{strategy?: 'replace' | 'merge' | 'deepMerge'}} [options]
|
|
397
|
+
* @returns {Promise<void>}
|
|
398
|
+
*/
|
|
399
|
+
async writeRecord(recordId, newValue, options) {
|
|
400
|
+
return this.recordSubscriptionManager.writeRecord(recordId, newValue, options)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** @param {string} recordId @returns {Promise<any>} */
|
|
404
|
+
async getRecord(recordId) {
|
|
405
|
+
return this.recordManager.getRecord(recordId)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** @param {string} recordId @returns {Promise<void>} */
|
|
409
|
+
async deleteRecord(recordId) {
|
|
410
|
+
const result = await this.recordManager.deleteRecord(recordId)
|
|
411
|
+
if (result) await this.recordSubscriptionManager.publishRecordDeletion(recordId, result.version)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* @param {string} pattern - redis glob pattern (e.g. "user:*")
|
|
416
|
+
* @param {{map?: (record: any) => any, sort?: (a: any, b: any) => number, slice?: {start: number, count: number}}} [options]
|
|
417
|
+
* @returns {Promise<any[]>}
|
|
418
|
+
*/
|
|
419
|
+
async listRecordsMatching(pattern, options) {
|
|
420
|
+
return this.collectionManager.listRecordsMatching(pattern, options)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* @param {ChannelPattern} pattern
|
|
425
|
+
* @param {(connection: Connection, collectionId: string) => Promise<any[]> | any[]} resolver
|
|
426
|
+
*/
|
|
427
|
+
exposeCollection(pattern, resolver) {
|
|
428
|
+
this.collectionManager.exposeCollection(pattern, resolver)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* @param {string} roomName
|
|
433
|
+
* @param {Connection | string} connection
|
|
434
|
+
* @returns {Promise<boolean>}
|
|
435
|
+
*/
|
|
436
|
+
async isInRoom(roomName, connection) {
|
|
437
|
+
const connectionId = typeof connection === "string" ? connection : connection.id
|
|
438
|
+
return this.roomManager.connectionIsInRoom(roomName, connectionId)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* @param {string} roomName
|
|
443
|
+
* @param {Connection | string} connection
|
|
444
|
+
* @returns {Promise<void>}
|
|
445
|
+
*/
|
|
446
|
+
async addToRoom(roomName, connection) {
|
|
447
|
+
const connectionId = typeof connection === "string" ? connection : connection.id
|
|
448
|
+
await this.roomManager.addToRoom(roomName, connection)
|
|
449
|
+
if (await this.presenceManager.isRoomTracked(roomName)) {
|
|
450
|
+
await this.presenceManager.markOnline(connectionId, roomName)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* @param {string} roomName
|
|
456
|
+
* @param {Connection | string} connection
|
|
457
|
+
* @returns {Promise<void>}
|
|
458
|
+
*/
|
|
459
|
+
async removeFromRoom(roomName, connection) {
|
|
460
|
+
const connectionId = typeof connection === "string" ? connection : connection.id
|
|
461
|
+
if (await this.presenceManager.isRoomTracked(roomName)) {
|
|
462
|
+
await this.presenceManager.markOffline(connectionId, roomName)
|
|
463
|
+
}
|
|
464
|
+
return this.roomManager.removeFromRoom(roomName, connection)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** @param {Connection | string} connection @returns {Promise<void>} */
|
|
468
|
+
async removeFromAllRooms(connection) {
|
|
469
|
+
return this.roomManager.removeFromAllRooms(connection)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** @param {string} roomName @returns {Promise<void>} */
|
|
473
|
+
async clearRoom(roomName) { return this.roomManager.clearRoom(roomName) }
|
|
474
|
+
/** @param {string} roomName @returns {Promise<void>} */
|
|
475
|
+
async deleteRoom(roomName) { return this.roomManager.deleteRoom(roomName) }
|
|
476
|
+
|
|
477
|
+
/** @param {string} roomName @returns {Promise<string[]>} */
|
|
478
|
+
async getRoomMembers(roomName) {
|
|
479
|
+
return this.roomManager.getRoomConnectionIds(roomName)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** @param {string} roomName @returns {Promise<Array<{id: string, metadata: any}>>} */
|
|
483
|
+
async getRoomMembersWithMetadata(roomName) {
|
|
484
|
+
const connectionIds = await this.roomManager.getRoomConnectionIds(roomName)
|
|
485
|
+
return Promise.all(
|
|
486
|
+
connectionIds.map(async (connectionId) => {
|
|
487
|
+
try {
|
|
488
|
+
const connection = this.connectionManager.getLocalConnection(connectionId)
|
|
489
|
+
let metadata
|
|
490
|
+
if (connection) {
|
|
491
|
+
metadata = await this.connectionManager.getMetadata(connection)
|
|
492
|
+
} else {
|
|
493
|
+
const metadataString = await this.redisManager.redis.hget("mesh:connection-meta", connectionId)
|
|
494
|
+
metadata = metadataString ? JSON.parse(metadataString) : null
|
|
495
|
+
}
|
|
496
|
+
return { id: connectionId, metadata }
|
|
497
|
+
} catch {
|
|
498
|
+
return { id: connectionId, metadata: null }
|
|
499
|
+
}
|
|
500
|
+
})
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** @returns {Promise<string[]>} */
|
|
505
|
+
async getAllRooms() { return this.roomManager.getAllRooms() }
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* @param {string} connectionId
|
|
509
|
+
* @returns {Promise<any>}
|
|
510
|
+
*/
|
|
511
|
+
async getConnectionMetadata(connectionId) {
|
|
512
|
+
return this.connectionManager.getMetadata(connectionId)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* @param {string} connectionId
|
|
517
|
+
* @param {any} metadata
|
|
518
|
+
* @param {{strategy?: 'replace' | 'merge' | 'deepMerge'}} [options]
|
|
519
|
+
* @returns {Promise<void>}
|
|
520
|
+
*/
|
|
521
|
+
async setConnectionMetadata(connectionId, metadata, options) {
|
|
522
|
+
return this.connectionManager.setMetadata(connectionId, metadata, options)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* @param {string} connectionId
|
|
527
|
+
* @param {string} command
|
|
528
|
+
* @param {any} payload
|
|
529
|
+
* @returns {Promise<void>}
|
|
530
|
+
*/
|
|
531
|
+
async sendTo(connectionId, command, payload) {
|
|
532
|
+
return this.broadcastManager.sendTo(connectionId, command, payload)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* @param {string} command
|
|
537
|
+
* @param {any} payload
|
|
538
|
+
* @param {Connection[]} [connections] - specific connections to target, or all if omitted
|
|
539
|
+
* @returns {Promise<void>}
|
|
540
|
+
*/
|
|
541
|
+
async broadcast(command, payload, connections) {
|
|
542
|
+
return this.broadcastManager.broadcast(command, payload, connections)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* @param {string} roomName
|
|
547
|
+
* @param {string} command
|
|
548
|
+
* @param {any} payload
|
|
549
|
+
* @returns {Promise<void>}
|
|
550
|
+
*/
|
|
551
|
+
async broadcastRoom(roomName, command, payload) {
|
|
552
|
+
return this.broadcastManager.broadcastRoom(roomName, command, payload)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* @param {string} command
|
|
557
|
+
* @param {any} payload
|
|
558
|
+
* @param {Connection | Connection[]} exclude
|
|
559
|
+
* @returns {Promise<void>}
|
|
560
|
+
*/
|
|
561
|
+
async broadcastExclude(command, payload, exclude) {
|
|
562
|
+
return this.broadcastManager.broadcastExclude(command, payload, exclude)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* @param {string} roomName
|
|
567
|
+
* @param {string} command
|
|
568
|
+
* @param {any} payload
|
|
569
|
+
* @param {Connection | Connection[]} exclude
|
|
570
|
+
* @returns {Promise<void>}
|
|
571
|
+
*/
|
|
572
|
+
async broadcastRoomExclude(roomName, command, payload, exclude) {
|
|
573
|
+
return this.broadcastManager.broadcastRoomExclude(roomName, command, payload, exclude)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* @param {ChannelPattern} roomPattern
|
|
578
|
+
* @param {((connection: Connection, roomName: string) => boolean | Promise<boolean>) | {ttl?: number, guard?: (connection: Connection, roomName: string) => boolean | Promise<boolean>}} [guardOrOptions]
|
|
579
|
+
*/
|
|
580
|
+
trackPresence(roomPattern, guardOrOptions) {
|
|
581
|
+
this.presenceManager.trackRoom(roomPattern, guardOrOptions)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
_registerBuiltinCommands() {
|
|
585
|
+
this.exposeCommand("mesh/noop", async () => true)
|
|
586
|
+
|
|
587
|
+
this.exposeCommand("mesh/subscribe-channel", async (ctx) => {
|
|
588
|
+
const { channel, historyLimit, since } = ctx.payload
|
|
589
|
+
if (!(await this.channelManager.isChannelExposed(channel, ctx.connection))) {
|
|
590
|
+
return { success: false, history: [] }
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
if (!this.channelManager.getSubscribers(channel)) {
|
|
594
|
+
await this.channelManager.subscribeToRedisChannel(channel)
|
|
595
|
+
}
|
|
596
|
+
this.channelManager.addSubscription(channel, ctx.connection)
|
|
597
|
+
const history = historyLimit && historyLimit > 0 ? await this.channelManager.getChannelHistory(channel, historyLimit, since) : []
|
|
598
|
+
return { success: true, history }
|
|
599
|
+
} catch {
|
|
600
|
+
return { success: false, history: [] }
|
|
601
|
+
}
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
this.exposeCommand("mesh/unsubscribe-channel", async (ctx) => {
|
|
605
|
+
const { channel } = ctx.payload
|
|
606
|
+
const wasSubscribed = this.channelManager.removeSubscription(channel, ctx.connection)
|
|
607
|
+
if (wasSubscribed && !this.channelManager.getSubscribers(channel)) {
|
|
608
|
+
await this.channelManager.unsubscribeFromRedisChannel(channel)
|
|
609
|
+
}
|
|
610
|
+
return wasSubscribed
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
this.exposeCommand("mesh/get-channel-history", async (ctx) => {
|
|
614
|
+
const { channel, limit, since } = ctx.payload
|
|
615
|
+
if (!(await this.channelManager.isChannelExposed(channel, ctx.connection))) {
|
|
616
|
+
return { success: false, history: [] }
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
if (this.persistenceManager?.getChannelPersistenceOptions(channel)) {
|
|
620
|
+
const messages = await this.persistenceManager.getMessages(
|
|
621
|
+
channel, since, limit || this.persistenceManager.getChannelPersistenceOptions(channel)?.historyLimit
|
|
622
|
+
)
|
|
623
|
+
return { success: true, history: messages.map((msg) => msg.message) }
|
|
624
|
+
} else {
|
|
625
|
+
const history = await this.channelManager.getChannelHistory(channel, limit || 50, since)
|
|
626
|
+
return { success: true, history }
|
|
627
|
+
}
|
|
628
|
+
} catch {
|
|
629
|
+
return { success: false, history: [] }
|
|
630
|
+
}
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
this.exposeCommand("mesh/join-room", async (ctx) => {
|
|
634
|
+
const { roomName } = ctx.payload
|
|
635
|
+
await this.addToRoom(roomName, ctx.connection)
|
|
636
|
+
const present = await this.getRoomMembersWithMetadata(roomName)
|
|
637
|
+
return { success: true, present }
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
this.exposeCommand("mesh/leave-room", async (ctx) => {
|
|
641
|
+
const { roomName } = ctx.payload
|
|
642
|
+
await this.removeFromRoom(roomName, ctx.connection)
|
|
643
|
+
return { success: true }
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
this.exposeCommand("mesh/get-connection-metadata", async (ctx) => {
|
|
647
|
+
const { connectionId } = ctx.payload
|
|
648
|
+
const connection = this.connectionManager.getLocalConnection(connectionId)
|
|
649
|
+
if (connection) {
|
|
650
|
+
const metadata = await this.connectionManager.getMetadata(connection)
|
|
651
|
+
return { metadata }
|
|
652
|
+
} else {
|
|
653
|
+
const metadata = await this.redisManager.redis.hget("mesh:connection-meta", connectionId)
|
|
654
|
+
return { metadata: metadata ? JSON.parse(metadata) : null }
|
|
655
|
+
}
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
this.exposeCommand("mesh/get-my-connection-metadata", async (ctx) => {
|
|
659
|
+
const connectionId = ctx.connection.id
|
|
660
|
+
const connection = this.connectionManager.getLocalConnection(connectionId)
|
|
661
|
+
if (connection) {
|
|
662
|
+
const metadata = await this.connectionManager.getMetadata(connection)
|
|
663
|
+
return { metadata }
|
|
664
|
+
} else {
|
|
665
|
+
const metadata = await this.redisManager.redis.hget("mesh:connection-meta", connectionId)
|
|
666
|
+
return { metadata: metadata ? JSON.parse(metadata) : null }
|
|
667
|
+
}
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
this.exposeCommand("mesh/set-my-connection-metadata", async (ctx) => {
|
|
671
|
+
const { metadata, options } = ctx.payload
|
|
672
|
+
const connectionId = ctx.connection.id
|
|
673
|
+
const connection = this.connectionManager.getLocalConnection(connectionId)
|
|
674
|
+
if (connection) {
|
|
675
|
+
try {
|
|
676
|
+
await this.connectionManager.setMetadata(connection, metadata, options)
|
|
677
|
+
return { success: true }
|
|
678
|
+
} catch {
|
|
679
|
+
return { success: false }
|
|
680
|
+
}
|
|
681
|
+
} else {
|
|
682
|
+
return { success: false }
|
|
683
|
+
}
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
this.exposeCommand("mesh/get-room-metadata", async (ctx) => {
|
|
687
|
+
const { roomName } = ctx.payload
|
|
688
|
+
const metadata = await this.roomManager.getMetadata(roomName)
|
|
689
|
+
return { metadata }
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
_registerRecordCommands() {
|
|
694
|
+
this.exposeCommand("mesh/subscribe-record", async (ctx) => {
|
|
695
|
+
const { recordId, mode = "full" } = ctx.payload
|
|
696
|
+
const connectionId = ctx.connection.id
|
|
697
|
+
if (!(await this.recordSubscriptionManager.isRecordExposed(recordId, ctx.connection))) {
|
|
698
|
+
return { success: false }
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
const { record, version } = await this.recordManager.getRecordAndVersion(recordId)
|
|
702
|
+
this.recordSubscriptionManager.addSubscription(recordId, connectionId, mode)
|
|
703
|
+
return { success: true, record, version }
|
|
704
|
+
} catch (e) {
|
|
705
|
+
serverLogger.error(`Failed to subscribe to record ${recordId}:`, e)
|
|
706
|
+
return { success: false }
|
|
707
|
+
}
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
this.exposeCommand("mesh/unsubscribe-record", async (ctx) => {
|
|
711
|
+
const { recordId } = ctx.payload
|
|
712
|
+
return this.recordSubscriptionManager.removeSubscription(recordId, ctx.connection.id)
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
this.exposeCommand("mesh/publish-record-update", async (ctx) => {
|
|
716
|
+
const { recordId, newValue, options } = ctx.payload
|
|
717
|
+
if (!(await this.recordSubscriptionManager.isRecordWritable(recordId, ctx.connection))) {
|
|
718
|
+
throw new Error(`Record "${recordId}" is not writable by this connection.`)
|
|
719
|
+
}
|
|
720
|
+
try {
|
|
721
|
+
await this.writeRecord(recordId, newValue, options)
|
|
722
|
+
return { success: true }
|
|
723
|
+
} catch (e) {
|
|
724
|
+
throw new Error(`Failed to publish update for record "${recordId}": ${e.message}`)
|
|
725
|
+
}
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
this.exposeCommand("mesh/subscribe-presence", async (ctx) => {
|
|
729
|
+
const { roomName } = ctx.payload
|
|
730
|
+
if (!(await this.presenceManager.isRoomTracked(roomName, ctx.connection))) {
|
|
731
|
+
return { success: false, present: [] }
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
const presenceChannel = `mesh:presence:updates:${roomName}`
|
|
735
|
+
this.channelManager.addSubscription(presenceChannel, ctx.connection)
|
|
736
|
+
if (!this.channelManager.getSubscribers(presenceChannel) || this.channelManager.getSubscribers(presenceChannel)?.size === 1) {
|
|
737
|
+
await this.channelManager.subscribeToRedisChannel(presenceChannel)
|
|
738
|
+
}
|
|
739
|
+
const present = await this.getRoomMembersWithMetadata(roomName)
|
|
740
|
+
const statesMap = await this.presenceManager.getAllPresenceStates(roomName)
|
|
741
|
+
const states = {}
|
|
742
|
+
statesMap.forEach((state, connectionId) => { states[connectionId] = state })
|
|
743
|
+
return { success: true, present, states }
|
|
744
|
+
} catch (e) {
|
|
745
|
+
serverLogger.error(`Failed to subscribe to presence for room ${roomName}:`, e)
|
|
746
|
+
return { success: false, present: [] }
|
|
747
|
+
}
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
this.exposeCommand("mesh/unsubscribe-presence", async (ctx) => {
|
|
751
|
+
const { roomName } = ctx.payload
|
|
752
|
+
const presenceChannel = `mesh:presence:updates:${roomName}`
|
|
753
|
+
return this.channelManager.removeSubscription(presenceChannel, ctx.connection)
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
this.exposeCommand("mesh/publish-presence-state", async (ctx) => {
|
|
757
|
+
const { roomName, state, expireAfter, silent } = ctx.payload
|
|
758
|
+
const connectionId = ctx.connection.id
|
|
759
|
+
if (!state) return false
|
|
760
|
+
if (!(await this.presenceManager.isRoomTracked(roomName, ctx.connection)) || !(await this.isInRoom(roomName, connectionId))) {
|
|
761
|
+
return false
|
|
762
|
+
}
|
|
763
|
+
try {
|
|
764
|
+
await this.presenceManager.publishPresenceState(connectionId, roomName, state, expireAfter, silent)
|
|
765
|
+
return true
|
|
766
|
+
} catch (e) {
|
|
767
|
+
serverLogger.error(`Failed to publish presence state for room ${roomName}:`, e)
|
|
768
|
+
return false
|
|
769
|
+
}
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
this.exposeCommand("mesh/clear-presence-state", async (ctx) => {
|
|
773
|
+
const { roomName } = ctx.payload
|
|
774
|
+
const connectionId = ctx.connection.id
|
|
775
|
+
if (!(await this.presenceManager.isRoomTracked(roomName, ctx.connection)) || !(await this.isInRoom(roomName, connectionId))) {
|
|
776
|
+
return false
|
|
777
|
+
}
|
|
778
|
+
try {
|
|
779
|
+
await this.presenceManager.clearPresenceState(connectionId, roomName)
|
|
780
|
+
return true
|
|
781
|
+
} catch (e) {
|
|
782
|
+
serverLogger.error(`Failed to clear presence state for room ${roomName}:`, e)
|
|
783
|
+
return false
|
|
784
|
+
}
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
this.exposeCommand("mesh/get-presence-state", async (ctx) => {
|
|
788
|
+
const { roomName } = ctx.payload
|
|
789
|
+
if (!(await this.presenceManager.isRoomTracked(roomName, ctx.connection))) {
|
|
790
|
+
return { success: false, present: [] }
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
const present = await this.presenceManager.getPresentConnections(roomName)
|
|
794
|
+
const statesMap = await this.presenceManager.getAllPresenceStates(roomName)
|
|
795
|
+
const states = {}
|
|
796
|
+
statesMap.forEach((state, connectionId) => { states[connectionId] = state })
|
|
797
|
+
return { success: true, present, states }
|
|
798
|
+
} catch (e) {
|
|
799
|
+
serverLogger.error(`Failed to get presence state for room ${roomName}:`, e)
|
|
800
|
+
return { success: false, present: [] }
|
|
801
|
+
}
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
this.exposeCommand("mesh/subscribe-collection", async (ctx) => {
|
|
805
|
+
const { collectionId } = ctx.payload
|
|
806
|
+
const connectionId = ctx.connection.id
|
|
807
|
+
if (!(await this.collectionManager.isCollectionExposed(collectionId, ctx.connection))) {
|
|
808
|
+
return { success: false, ids: [], records: [], version: 0 }
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
const { ids, records, version } = await this.collectionManager.addSubscription(collectionId, connectionId, ctx.connection)
|
|
812
|
+
const recordsWithId = records.map((record) => ({ id: record.id, record }))
|
|
813
|
+
return { success: true, ids, records: recordsWithId, version }
|
|
814
|
+
} catch (e) {
|
|
815
|
+
serverLogger.error(`Failed to subscribe to collection ${collectionId}:`, e)
|
|
816
|
+
return { success: false, ids: [], records: [], version: 0 }
|
|
817
|
+
}
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
this.exposeCommand("mesh/unsubscribe-collection", async (ctx) => {
|
|
821
|
+
const { collectionId } = ctx.payload
|
|
822
|
+
return this.collectionManager.removeSubscription(collectionId, ctx.connection.id)
|
|
823
|
+
})
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async cleanupConnection(connection) {
|
|
827
|
+
serverLogger.info("Cleaning up connection:", connection.id)
|
|
828
|
+
connection.stopIntervals()
|
|
829
|
+
try {
|
|
830
|
+
await this.presenceManager.cleanupConnection(connection)
|
|
831
|
+
await this.connectionManager.cleanupConnection(connection)
|
|
832
|
+
await this.roomManager.cleanupConnection(connection)
|
|
833
|
+
this.recordSubscriptionManager.cleanupConnection(connection)
|
|
834
|
+
this.channelManager.cleanupConnection(connection)
|
|
835
|
+
await this.collectionManager.cleanupConnection(connection)
|
|
836
|
+
} catch (err) {
|
|
837
|
+
this._emitError(new Error(`Failed to clean up connection: ${err}`))
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** @returns {Promise<void>} */
|
|
842
|
+
async close() {
|
|
843
|
+
this.redisManager.isShuttingDown = true
|
|
844
|
+
|
|
845
|
+
const connections = this.connectionManager.getLocalConnections()
|
|
846
|
+
await Promise.all(
|
|
847
|
+
connections.map(async (connection) => {
|
|
848
|
+
if (!connection.isDead) await connection.close()
|
|
849
|
+
await this.cleanupConnection(connection)
|
|
850
|
+
})
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
if (this._wss) {
|
|
854
|
+
await new Promise((resolve, reject) => {
|
|
855
|
+
this._wss.close((err) => { if (err) reject(err); else resolve() })
|
|
856
|
+
})
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (this.persistenceManager) {
|
|
860
|
+
try { await this.persistenceManager.shutdown() }
|
|
861
|
+
catch (err) { serverLogger.error("Error shutting down persistence manager:", err) }
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
await this.channelManager.cleanupAllSubscriptions()
|
|
865
|
+
await this.instanceManager.stop()
|
|
866
|
+
await this.pubSubManager.cleanup()
|
|
867
|
+
await this.presenceManager.cleanup()
|
|
868
|
+
|
|
869
|
+
this.redisManager.disconnect()
|
|
870
|
+
|
|
871
|
+
if (this._httpServer && this._ownsHttpServer) {
|
|
872
|
+
await new Promise((resolve) => { this._httpServer.close(resolve) })
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
this._listening = false
|
|
876
|
+
this.status = Status.OFFLINE
|
|
877
|
+
}
|
|
878
|
+
}
|