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