@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,17 @@
1
+ import { defineConfig } from 'vite'
2
+ import vue from '@vitejs/plugin-vue'
3
+
4
+ export default defineConfig({
5
+ plugins: [vue()],
6
+ base: './',
7
+ build: {
8
+ outDir: 'dist',
9
+ emptyOutDir: true
10
+ },
11
+ server: {
12
+ port: 4173,
13
+ proxy: {
14
+ '/api': 'http://localhost:3399/devtools'
15
+ }
16
+ }
17
+ })
@@ -0,0 +1,144 @@
1
+ import express from "express"
2
+ import http from "node:http"
3
+ import { RealtimeServer } from "../../../src/index.js"
4
+ import { RealtimeClient } from "../../../src/client/index.js"
5
+ import { createDevtools } from "../index.js"
6
+
7
+ const PORT = 3399
8
+
9
+ const app = express()
10
+ const httpServer = http.createServer(app)
11
+
12
+ const mesh = new RealtimeServer({
13
+ redis: { host: "127.0.0.1", port: 6379, db: 15 }
14
+ })
15
+
16
+ mesh.exposeChannel(/^chat:.*/)
17
+ mesh.exposeChannel("notifications")
18
+ mesh.exposeRecord(/^user:.*/)
19
+ mesh.exposeWritableRecord(/^doc:.*/)
20
+ mesh.trackPresence(/^room:.*/)
21
+
22
+ mesh.exposeCollection("users:online", async () => {
23
+ return await mesh.listRecordsMatching("user:*")
24
+ })
25
+
26
+ mesh.exposeCollection("docs:shared", async () => {
27
+ return await mesh.listRecordsMatching("doc:*")
28
+ })
29
+
30
+ mesh.exposeCollection(/^users-by-role:.*/, async (_conn, collectionId) => {
31
+ const role = collectionId.split(":")[1]
32
+ const users = await mesh.listRecordsMatching("user:*")
33
+ return users.filter(u => u.role === role)
34
+ })
35
+
36
+ mesh.exposeCommand("echo", (ctx) => ctx.payload)
37
+
38
+ app.use("/devtools", createDevtools(mesh))
39
+
40
+ const redis = mesh.redisManager.redis
41
+ await redis.flushdb()
42
+
43
+ await mesh.attach(httpServer, { port: PORT })
44
+
45
+ const names = ["alice", "bob", "charlie", "diana", "eve"]
46
+ const statuses = ["online", "away", "busy", "idle"]
47
+ const rooms = ["room:lobby", "room:general", "room:random"]
48
+ const channels = ["chat:general", "chat:random", "notifications"]
49
+
50
+ const clients = []
51
+
52
+ for (let i = 0; i < names.length; i++) {
53
+ const client = new RealtimeClient(`ws://localhost:${PORT}`)
54
+ await client.connect()
55
+
56
+ await mesh.setConnectionMetadata(client.connectionId, {
57
+ name: names[i],
58
+ role: i === 0 ? "admin" : "member",
59
+ joinedAt: new Date().toISOString()
60
+ })
61
+
62
+ const role = i === 0 ? "admin" : "member"
63
+ await mesh.writeRecord(`user:${names[i]}`, {
64
+ id: `user:${names[i]}`,
65
+ name: names[i],
66
+ email: `${names[i]}@example.com`,
67
+ role,
68
+ active: true
69
+ })
70
+
71
+ if (i < 3) {
72
+ await mesh.writeRecord(`doc:doc-${i}`, {
73
+ id: `doc:doc-${i}`,
74
+ title: `Document ${i}`,
75
+ content: "lorem ipsum",
76
+ author: names[i]
77
+ })
78
+ }
79
+
80
+ const clientRooms = rooms.slice(0, 1 + (i % rooms.length))
81
+ for (const room of clientRooms) {
82
+ await client.joinRoom(room)
83
+ await client.subscribePresence(room, () => {})
84
+ }
85
+ await new Promise(r => setTimeout(r, 100))
86
+ for (const room of clientRooms) {
87
+ await client.publishPresenceState(room, {
88
+ state: {
89
+ status: statuses[i % statuses.length],
90
+ cursor: { x: Math.floor(Math.random() * 800), y: Math.floor(Math.random() * 600) }
91
+ }
92
+ })
93
+ }
94
+
95
+ const clientChannels = channels.slice(0, 1 + (i % channels.length))
96
+ for (const ch of clientChannels) {
97
+ await client.subscribeChannel(ch, () => {})
98
+ }
99
+
100
+ await client.subscribeRecord(`user:${names[i]}`, () => {})
101
+ if (i < 3) await client.subscribeRecord(`doc:doc-${i}`, () => {})
102
+
103
+ clients.push(client)
104
+ }
105
+
106
+ await new Promise(r => setTimeout(r, 300))
107
+
108
+ for (const client of clients) {
109
+ await client.subscribeCollection("users:online", { onDiff: () => {} })
110
+ }
111
+
112
+ for (const client of clients.slice(0, 3)) {
113
+ await client.subscribeCollection("docs:shared", { onDiff: () => {} })
114
+ }
115
+
116
+ await clients[0].subscribeCollection("users-by-role:admin", { onDiff: () => {} })
117
+ await clients[1].subscribeCollection("users-by-role:member", { onDiff: () => {} })
118
+ await clients[2].subscribeCollection("users-by-role:admin", { onDiff: () => {} })
119
+
120
+ setInterval(async () => {
121
+ const i = Math.floor(Math.random() * clients.length)
122
+ const room = rooms[Math.floor(Math.random() * rooms.length)]
123
+ const clientRooms = rooms.slice(0, 1 + (i % rooms.length))
124
+ if (!clientRooms.includes(room)) return
125
+
126
+ await clients[i].publishPresenceState(room, {
127
+ state: {
128
+ status: statuses[Math.floor(Math.random() * statuses.length)],
129
+ cursor: { x: Math.floor(Math.random() * 800), y: Math.floor(Math.random() * 600) }
130
+ }
131
+ })
132
+ }, 3000)
133
+
134
+ setInterval(async () => {
135
+ const ch = channels[Math.floor(Math.random() * channels.length)]
136
+ await mesh.writeChannel(ch, {
137
+ from: names[Math.floor(Math.random() * names.length)],
138
+ text: `message at ${new Date().toISOString()}`,
139
+ }, 20)
140
+ }, 5000)
141
+
142
+ console.log(`mesh devtools demo running on http://localhost:${PORT}/devtools`)
143
+ console.log(`${names.length} clients connected, ${rooms.length} rooms, ${channels.length} channels`)
144
+ console.log(`vite dev server: cd packages/devtools/client && npm run dev`)
@@ -0,0 +1,186 @@
1
+ import express from "express"
2
+ import { fileURLToPath } from "node:url"
3
+ import { dirname, join } from "node:path"
4
+ import { existsSync } from "node:fs"
5
+
6
+ function patternToString(p) {
7
+ if (typeof p === "string") return p
8
+ if (p instanceof RegExp) return p.toString()
9
+ return String(p)
10
+ }
11
+
12
+ export function createDevtools(server) {
13
+ const router = express.Router()
14
+ const __dirname = dirname(fileURLToPath(import.meta.url))
15
+ const distPath = join(__dirname, "client", "dist")
16
+
17
+ router.get("/api/state", async (_req, res) => {
18
+ try {
19
+ const connIds = await server.connectionManager.getAllConnectionIds()
20
+ const allMeta = await server.connectionManager._getMetadataForConnectionIds(connIds)
21
+ const localConns = server.connectionManager.getLocalConnections()
22
+ const localMap = {}
23
+ for (const conn of localConns) {
24
+ localMap[conn.id] = conn
25
+ }
26
+
27
+ const connections = allMeta.map(({ id, metadata }) => {
28
+ const local = localMap[id]
29
+ return {
30
+ id,
31
+ metadata,
32
+ local: !!local,
33
+ latency: local?.latency?.ms ?? null,
34
+ alive: local?.alive ?? null,
35
+ remoteAddress: local?.remoteAddress ?? null
36
+ }
37
+ })
38
+
39
+ const roomNames = await server.roomManager.getAllRooms()
40
+ const rooms = []
41
+ for (const name of roomNames) {
42
+ const members = await server.roomManager.getRoomConnectionIds(name)
43
+ const statesMap = await server.presenceManager.getAllPresenceStates(name)
44
+ const presence = {}
45
+ statesMap.forEach((state, connId) => { presence[connId] = state })
46
+ rooms.push({ name, members, presence })
47
+ }
48
+
49
+ const channels = {}
50
+ for (const [channel, subscribers] of Object.entries(server.channelManager.channelSubscriptions)) {
51
+ if (channel.startsWith("mesh:presence:updates:")) continue
52
+ channels[channel] = [...subscribers].map(c => c.id)
53
+ }
54
+
55
+ const collections = {}
56
+ server.collectionManager.collectionSubscriptions.forEach((subs, collId) => {
57
+ const subscribers = {}
58
+ subs.forEach((info, connId) => { subscribers[connId] = info })
59
+ collections[collId] = { subscribers }
60
+ })
61
+
62
+ const records = {}
63
+ server.recordSubscriptionManager.recordSubscriptions.forEach((subs, recordId) => {
64
+ const subscribers = {}
65
+ subs.forEach((mode, connId) => { subscribers[connId] = mode })
66
+ records[recordId] = { subscribers }
67
+ })
68
+
69
+ const exposed = {
70
+ channels: server.channelManager.exposedChannels.map(patternToString),
71
+ records: server.recordSubscriptionManager.exposedRecords.map(patternToString),
72
+ writableRecords: server.recordSubscriptionManager.exposedWritableRecords.map(patternToString),
73
+ collections: server.collectionManager.exposedCollections.map(e => patternToString(e.pattern)),
74
+ presence: server.presenceManager.trackedRooms.map(patternToString),
75
+ commands: server.commandManager.commands
76
+ ? Object.keys(server.commandManager.commands).filter(c => !c.startsWith("mesh/"))
77
+ : []
78
+ }
79
+
80
+ res.json({
81
+ instanceId: server.instanceId,
82
+ connections,
83
+ rooms,
84
+ channels,
85
+ collections,
86
+ records,
87
+ exposed
88
+ })
89
+ } catch (err) {
90
+ res.status(500).json({ error: err.message })
91
+ }
92
+ })
93
+
94
+ router.get("/api/connection/:id", async (req, res) => {
95
+ try {
96
+ const { id } = req.params
97
+ const metadata = await server.connectionManager.getMetadata(id)
98
+ const rooms = await server.roomManager.getRoomsForConnection(id)
99
+
100
+ const presence = {}
101
+ for (const room of rooms) {
102
+ const state = await server.presenceManager.getPresenceState(id, room)
103
+ if (state) presence[room] = state
104
+ }
105
+
106
+ const channels = []
107
+ for (const [channel, subscribers] of Object.entries(server.channelManager.channelSubscriptions)) {
108
+ if (channel.startsWith("mesh:presence:updates:")) continue
109
+ for (const conn of subscribers) {
110
+ if (conn.id === id) { channels.push(channel); break }
111
+ }
112
+ }
113
+
114
+ const collections = []
115
+ server.collectionManager.collectionSubscriptions.forEach((subs, collId) => {
116
+ if (subs.has(id)) collections.push({ id: collId, ...subs.get(id) })
117
+ })
118
+
119
+ const records = []
120
+ server.recordSubscriptionManager.recordSubscriptions.forEach((subs, recordId) => {
121
+ if (subs.has(id)) records.push({ id: recordId, mode: subs.get(id) })
122
+ })
123
+
124
+ const local = server.connectionManager.getLocalConnection(id)
125
+
126
+ res.json({
127
+ id,
128
+ metadata,
129
+ rooms,
130
+ presence,
131
+ channels,
132
+ collections,
133
+ records,
134
+ local: !!local,
135
+ latency: local?.latency?.ms ?? null,
136
+ alive: local?.alive ?? null,
137
+ remoteAddress: local?.remoteAddress ?? null
138
+ })
139
+ } catch (err) {
140
+ res.status(500).json({ error: err.message })
141
+ }
142
+ })
143
+
144
+ router.get("/api/room/:name", async (req, res) => {
145
+ try {
146
+ const { name } = req.params
147
+ const membersWithMeta = await server.getRoomMembersWithMetadata(name)
148
+ const statesMap = await server.presenceManager.getAllPresenceStates(name)
149
+ const presence = {}
150
+ statesMap.forEach((state, connId) => { presence[connId] = state })
151
+ res.json({ name, members: membersWithMeta, presence })
152
+ } catch (err) {
153
+ res.status(500).json({ error: err.message })
154
+ }
155
+ })
156
+
157
+ router.get("/api/collection/:id/records", async (req, res) => {
158
+ try {
159
+ const collId = req.params.id
160
+ const connId = req.query.connId
161
+ if (!connId) return res.status(400).json({ error: "connId query param required" })
162
+
163
+ const raw = await server.redisManager.redis.get(`mesh:collection:${collId}:${connId}`)
164
+ if (!raw) return res.json({ recordIds: [], records: [] })
165
+
166
+ const recordIds = JSON.parse(raw)
167
+ const records = []
168
+ for (const rid of recordIds) {
169
+ const data = await server.recordManager.getRecord(rid)
170
+ records.push({ id: rid, data })
171
+ }
172
+ res.json({ recordIds, records })
173
+ } catch (err) {
174
+ res.status(500).json({ error: err.message })
175
+ }
176
+ })
177
+
178
+ if (existsSync(distPath)) {
179
+ router.use(express.static(distPath))
180
+ router.get("/{*splat}", (_req, res) => {
181
+ res.sendFile(join(distPath, "index.html"))
182
+ })
183
+ }
184
+
185
+ return router
186
+ }
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { RealtimeServer } from "./server/server.js"
2
+ export { Context } from "./server/context.js"
3
+ export { Connection } from "./server/connection.js"
4
+ export { RoomManager } from "./server/managers/rooms.js"
5
+ export { RecordManager } from "./server/managers/records.js"
6
+ export { ConnectionManager } from "./server/managers/connections.js"
7
+ export { PresenceManager } from "./server/managers/presence.js"
8
+ export { PersistenceManager } from "./server/managers/persistence.js"
9
+ export { MessageStream } from "./server/message-stream.js"
@@ -0,0 +1,116 @@
1
+ import { EventEmitter } from "node:events"
2
+ import { parseCommand, stringifyCommand, serverLogger, Status } from "../shared/index.js"
3
+ import { generateConnectionId } from "./utils/ids.js"
4
+
5
+ export class Connection extends EventEmitter {
6
+ constructor(socket, req, options, server) {
7
+ super()
8
+ this.socket = socket
9
+ this.id = generateConnectionId()
10
+ this.alive = true
11
+ this.missedPongs = 0
12
+ this.remoteAddress = req.socket.remoteAddress
13
+ this.connectionOptions = options
14
+ this.server = server
15
+ this.status = Status.ONLINE
16
+ this.latency = { start: 0, end: 0, ms: 0, interval: null }
17
+ this.ping = { interval: null }
18
+
19
+ this._applyListeners()
20
+ this._startIntervals()
21
+ }
22
+
23
+ get isDead() {
24
+ return !this.socket || this.socket.readyState !== this.socket.constructor.OPEN
25
+ }
26
+
27
+ _startIntervals() {
28
+ this.latency.interval = setInterval(() => {
29
+ if (!this.alive) return
30
+ if (typeof this.latency.ms === "number") {
31
+ this.send({ command: "latency", payload: this.latency.ms })
32
+ }
33
+ this.latency.start = Date.now()
34
+ this.send({ command: "latency:request", payload: {} })
35
+ }, this.connectionOptions.latencyInterval)
36
+
37
+ this.ping.interval = setInterval(() => {
38
+ if (!this.alive) {
39
+ this.missedPongs++
40
+ const maxMissedPongs = this.connectionOptions.maxMissedPongs ?? 1
41
+ if (this.missedPongs > maxMissedPongs) {
42
+ serverLogger.info(`Closing connection (${this.id}) due to missed pongs`)
43
+ this.close()
44
+ this.server.cleanupConnection(this)
45
+ return
46
+ }
47
+ } else {
48
+ this.missedPongs = 0
49
+ }
50
+ this.alive = false
51
+ this.send({ command: "ping", payload: {} })
52
+ }, this.connectionOptions.pingInterval)
53
+ }
54
+
55
+ stopIntervals() {
56
+ clearInterval(this.latency.interval)
57
+ clearInterval(this.ping.interval)
58
+ }
59
+
60
+ _applyListeners() {
61
+ this.socket.on("close", () => {
62
+ serverLogger.info("Client's socket closed:", this.id)
63
+ this.status = Status.OFFLINE
64
+ this.emit("close")
65
+ })
66
+
67
+ this.socket.on("error", (error) => {
68
+ this.emit("error", error)
69
+ })
70
+
71
+ this.socket.on("message", (data) => {
72
+ try {
73
+ const command = parseCommand(data.toString())
74
+ if (command.command === "latency:response") {
75
+ this.latency.end = Date.now()
76
+ this.latency.ms = this.latency.end - this.latency.start
77
+ return
78
+ } else if (command.command === "pong") {
79
+ this.alive = true
80
+ this.missedPongs = 0
81
+ this.emit("pong", this.id)
82
+ return
83
+ }
84
+ this.emit("message", data)
85
+ } catch (error) {
86
+ this.emit("error", error)
87
+ }
88
+ })
89
+ }
90
+
91
+ send(cmd) {
92
+ if (this.isDead) return false
93
+ try {
94
+ this.socket.send(stringifyCommand(cmd))
95
+ return true
96
+ } catch (error) {
97
+ this.emit("error", error)
98
+ return false
99
+ }
100
+ }
101
+
102
+ async close() {
103
+ if (this.isDead) return false
104
+ try {
105
+ await new Promise((resolve, reject) => {
106
+ this.socket.once("close", resolve)
107
+ this.socket.once("error", reject)
108
+ this.socket.close()
109
+ })
110
+ return true
111
+ } catch (error) {
112
+ this.emit("error", error)
113
+ return false
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,22 @@
1
+ export class Context {
2
+ constructor(server, command, connection, payload) {
3
+ this.server = server
4
+ this.command = command
5
+ this.connection = connection
6
+ this.payload = payload
7
+ }
8
+
9
+ /** @returns {Promise<any>} */
10
+ getMetadata() {
11
+ return this.server.connectionManager.getMetadata(this.connection)
12
+ }
13
+
14
+ /**
15
+ * @param {any} metadata
16
+ * @param {{strategy?: 'replace' | 'merge' | 'deepMerge'}} [options]
17
+ * @returns {Promise<void>}
18
+ */
19
+ setMetadata(metadata, options) {
20
+ return this.server.connectionManager.setMetadata(this.connection, metadata, options)
21
+ }
22
+ }
@@ -0,0 +1,94 @@
1
+ export class BroadcastManager {
2
+ constructor({ connectionManager, roomManager, instanceId, pubClient, getPubSubChannel, emitError }) {
3
+ this.connectionManager = connectionManager
4
+ this.roomManager = roomManager
5
+ this.instanceId = instanceId
6
+ this.pubClient = pubClient
7
+ this.getPubSubChannel = getPubSubChannel
8
+ this.emitError = emitError
9
+ }
10
+
11
+ async sendTo(connectionId, command, payload) {
12
+ try {
13
+ await this._publishOrSend([connectionId], { command, payload })
14
+ } catch (err) {
15
+ this.emitError(new Error(`Failed to send command "${command}" to ${connectionId}: ${err}`))
16
+ }
17
+ }
18
+
19
+ async broadcast(command, payload, connections) {
20
+ const cmd = { command, payload }
21
+ try {
22
+ if (connections) {
23
+ const allConnectionIds = connections.map(({ id }) => id)
24
+ const connectionIds = await this.connectionManager.getAllConnectionIds()
25
+ const filteredIds = allConnectionIds.filter((id) => connectionIds.includes(id))
26
+ await this._publishOrSend(filteredIds, cmd)
27
+ } else {
28
+ const allConnectionIds = await this.connectionManager.getAllConnectionIds()
29
+ await this._publishOrSend(allConnectionIds, cmd)
30
+ }
31
+ } catch (err) {
32
+ this.emitError(new Error(`Failed to broadcast command "${command}": ${err}`))
33
+ }
34
+ }
35
+
36
+ async broadcastRoom(roomName, command, payload) {
37
+ const connectionIds = await this.roomManager.getRoomConnectionIds(roomName)
38
+ try {
39
+ await this._publishOrSend(connectionIds, { command, payload })
40
+ } catch (err) {
41
+ this.emitError(new Error(`Failed to broadcast command "${command}": ${err}`))
42
+ }
43
+ }
44
+
45
+ async broadcastExclude(command, payload, exclude) {
46
+ const excludedIds = new Set((Array.isArray(exclude) ? exclude : [exclude]).map(({ id }) => id))
47
+ try {
48
+ const connectionIds = (await this.connectionManager.getAllConnectionIds()).filter((id) => !excludedIds.has(id))
49
+ await this._publishOrSend(connectionIds, { command, payload })
50
+ } catch (err) {
51
+ this.emitError(new Error(`Failed to broadcast command "${command}": ${err}`))
52
+ }
53
+ }
54
+
55
+ async broadcastRoomExclude(roomName, command, payload, exclude) {
56
+ const excludedIds = new Set((Array.isArray(exclude) ? exclude : [exclude]).map(({ id }) => id))
57
+ try {
58
+ const connectionIds = (await this.roomManager.getRoomConnectionIds(roomName)).filter((id) => !excludedIds.has(id))
59
+ await this._publishOrSend(connectionIds, { command, payload })
60
+ } catch (err) {
61
+ this.emitError(new Error(`Failed to broadcast command "${command}": ${err}`))
62
+ }
63
+ }
64
+
65
+ async _publishOrSend(connectionIds, command) {
66
+ if (connectionIds.length === 0) return
67
+ const connectionInstanceMapping = await this.connectionManager.getInstanceIdsForConnections(connectionIds)
68
+ const instanceMap = {}
69
+ for (const connectionId of connectionIds) {
70
+ const instanceId = connectionInstanceMapping[connectionId]
71
+ if (instanceId) {
72
+ if (!instanceMap[instanceId]) instanceMap[instanceId] = []
73
+ instanceMap[instanceId].push(connectionId)
74
+ }
75
+ }
76
+ for (const [instanceId, targetConnectionIds] of Object.entries(instanceMap)) {
77
+ if (targetConnectionIds.length === 0) continue
78
+ if (instanceId === this.instanceId) {
79
+ targetConnectionIds.forEach((connectionId) => {
80
+ const connection = this.connectionManager.getLocalConnection(connectionId)
81
+ if (connection && !connection.isDead) connection.send(command)
82
+ })
83
+ } else {
84
+ const messagePayload = { targetConnectionIds, command }
85
+ const message = JSON.stringify(messagePayload)
86
+ try {
87
+ await this.pubClient.publish(this.getPubSubChannel(instanceId), message)
88
+ } catch (err) {
89
+ this.emitError(new Error(`Failed to publish command "${command.command}": ${err}`))
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }