@prsm/realtime 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +49 -0
  2. package/src/adapters/postgres.js +139 -0
  3. package/src/adapters/sqlite.js +177 -0
  4. package/src/client/client.js +441 -0
  5. package/src/client/connection.js +178 -0
  6. package/src/client/ids.js +26 -0
  7. package/src/client/index.js +5 -0
  8. package/src/client/queue.js +44 -0
  9. package/src/client/subscriptions/channels.js +79 -0
  10. package/src/client/subscriptions/collections.js +96 -0
  11. package/src/client/subscriptions/presence.js +98 -0
  12. package/src/client/subscriptions/records.js +123 -0
  13. package/src/client/subscriptions/rooms.js +69 -0
  14. package/src/devtools/client/dist/assets/index-CGm1NqOQ.css +1 -0
  15. package/src/devtools/client/dist/assets/index-w2FI7RvC.js +168 -0
  16. package/src/devtools/client/dist/index.html +16 -0
  17. package/src/devtools/client/index.html +15 -0
  18. package/src/devtools/client/package.json +17 -0
  19. package/src/devtools/client/src/App.vue +173 -0
  20. package/src/devtools/client/src/components/ConnectionPicker.vue +38 -0
  21. package/src/devtools/client/src/components/JsonView.vue +18 -0
  22. package/src/devtools/client/src/composables/useApi.js +71 -0
  23. package/src/devtools/client/src/composables/useHighlight.js +57 -0
  24. package/src/devtools/client/src/main.js +5 -0
  25. package/src/devtools/client/src/style.css +440 -0
  26. package/src/devtools/client/src/views/ChannelsView.vue +27 -0
  27. package/src/devtools/client/src/views/CollectionsView.vue +61 -0
  28. package/src/devtools/client/src/views/MetadataView.vue +108 -0
  29. package/src/devtools/client/src/views/RecordsView.vue +30 -0
  30. package/src/devtools/client/src/views/RoomsView.vue +39 -0
  31. package/src/devtools/client/vite.config.js +17 -0
  32. package/src/devtools/demo/server.js +144 -0
  33. package/src/devtools/index.js +186 -0
  34. package/src/index.js +9 -0
  35. package/src/server/connection.js +116 -0
  36. package/src/server/context.js +22 -0
  37. package/src/server/managers/broadcast.js +94 -0
  38. package/src/server/managers/channels.js +118 -0
  39. package/src/server/managers/collections.js +127 -0
  40. package/src/server/managers/commands.js +55 -0
  41. package/src/server/managers/connections.js +111 -0
  42. package/src/server/managers/instance.js +125 -0
  43. package/src/server/managers/persistence.js +371 -0
  44. package/src/server/managers/presence.js +217 -0
  45. package/src/server/managers/pubsub.js +242 -0
  46. package/src/server/managers/record-subscriptions.js +123 -0
  47. package/src/server/managers/records.js +110 -0
  48. package/src/server/managers/redis.js +61 -0
  49. package/src/server/managers/rooms.js +129 -0
  50. package/src/server/message-stream.js +20 -0
  51. package/src/server/server.js +878 -0
  52. package/src/server/utils/constants.js +4 -0
  53. package/src/server/utils/ids.js +5 -0
  54. package/src/server/utils/pattern-conversion.js +14 -0
  55. package/src/shared/errors.js +7 -0
  56. package/src/shared/index.js +5 -0
  57. package/src/shared/logger.js +53 -0
  58. package/src/shared/merge.js +17 -0
  59. package/src/shared/message.js +17 -0
  60. package/src/shared/status.js +7 -0
@@ -0,0 +1,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
+ }