@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,441 @@
|
|
|
1
|
+
import { EventEmitter } from "eventemitter3"
|
|
2
|
+
import { Connection } from "./connection.js"
|
|
3
|
+
import { clientLogger, CodeError, LogLevel, Status } from "../shared/index.js"
|
|
4
|
+
import { createRecordSubscriptions } from "./subscriptions/records.js"
|
|
5
|
+
import { createChannelSubscriptions } from "./subscriptions/channels.js"
|
|
6
|
+
import { createPresenceSubscriptions } from "./subscriptions/presence.js"
|
|
7
|
+
import { createCollectionSubscriptions } from "./subscriptions/collections.js"
|
|
8
|
+
import { createRoomSubscriptions } from "./subscriptions/rooms.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} RealtimeClientOptions
|
|
12
|
+
* @property {number} [pingTimeout] - ms before a ping is considered missed (default 30000)
|
|
13
|
+
* @property {number} [maxMissedPings] - missed pings before reconnect (default 1)
|
|
14
|
+
* @property {boolean} [shouldReconnect] - auto-reconnect on disconnect (default true)
|
|
15
|
+
* @property {number} [reconnectInterval] - ms between reconnect attempts (default 2000)
|
|
16
|
+
* @property {number} [maxReconnectAttempts] - max reconnect attempts (default Infinity)
|
|
17
|
+
* @property {number} [logLevel] - log level from LogLevel enum
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export class RealtimeClient extends EventEmitter {
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} url - websocket server URL
|
|
23
|
+
* @param {RealtimeClientOptions} [opts]
|
|
24
|
+
*/
|
|
25
|
+
constructor(url, opts = {}) {
|
|
26
|
+
super()
|
|
27
|
+
this.url = url
|
|
28
|
+
this.socket = null
|
|
29
|
+
this.pingTimeout = undefined
|
|
30
|
+
this.missedPings = 0
|
|
31
|
+
this.isReconnecting = false
|
|
32
|
+
this._status = Status.OFFLINE
|
|
33
|
+
this._lastActivityTime = Date.now()
|
|
34
|
+
this._isBrowser = false
|
|
35
|
+
|
|
36
|
+
this.connection = new Connection(null)
|
|
37
|
+
this.options = {
|
|
38
|
+
pingTimeout: opts.pingTimeout ?? 30_000,
|
|
39
|
+
maxMissedPings: opts.maxMissedPings ?? 1,
|
|
40
|
+
shouldReconnect: opts.shouldReconnect ?? true,
|
|
41
|
+
reconnectInterval: opts.reconnectInterval ?? 2_000,
|
|
42
|
+
maxReconnectAttempts: opts.maxReconnectAttempts ?? Infinity,
|
|
43
|
+
logLevel: opts.logLevel ?? LogLevel.ERROR,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
clientLogger.configure({ level: this.options.logLevel, styling: true })
|
|
47
|
+
|
|
48
|
+
this.recordSubscriptions = new Map()
|
|
49
|
+
this.collectionSubscriptions = new Map()
|
|
50
|
+
this.presenceSubscriptions = new Map()
|
|
51
|
+
this.joinedRooms = new Map()
|
|
52
|
+
this.channelSubscriptions = new Map()
|
|
53
|
+
|
|
54
|
+
this._records = createRecordSubscriptions(this)
|
|
55
|
+
this._channels = createChannelSubscriptions(this)
|
|
56
|
+
this._presence = createPresenceSubscriptions(this)
|
|
57
|
+
this._collections = createCollectionSubscriptions(this)
|
|
58
|
+
this._rooms = createRoomSubscriptions(this, this._presence)
|
|
59
|
+
|
|
60
|
+
this._setupConnectionEvents()
|
|
61
|
+
this._setupVisibilityHandling()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @returns {string} current connection status */
|
|
65
|
+
get status() { return this._status }
|
|
66
|
+
/** @returns {string|undefined} the server-assigned connection id */
|
|
67
|
+
get connectionId() { return this.connection.connectionId }
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} recordId
|
|
71
|
+
* @param {(update: {recordId: string, full?: any, patch?: import('fast-json-patch').Operation[], version: number, deleted?: boolean}) => void} callback
|
|
72
|
+
* @param {{mode?: 'full' | 'patch'}} [options]
|
|
73
|
+
* @returns {Promise<{success: boolean, record: any, version: number}>}
|
|
74
|
+
*/
|
|
75
|
+
subscribeRecord(recordId, callback, options) { return this._records.subscribe(recordId, callback, options) }
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} recordId
|
|
78
|
+
* @returns {Promise<boolean>}
|
|
79
|
+
*/
|
|
80
|
+
unsubscribeRecord(recordId) { return this._records.unsubscribe(recordId) }
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} recordId
|
|
83
|
+
* @param {any} newValue
|
|
84
|
+
* @param {Object} [options]
|
|
85
|
+
* @returns {Promise<boolean>}
|
|
86
|
+
*/
|
|
87
|
+
writeRecord(recordId, newValue, options) { return this._records.write(recordId, newValue, options) }
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} channel
|
|
91
|
+
* @param {(message: any) => void} callback
|
|
92
|
+
* @param {{historyLimit?: number, since?: string}} [options]
|
|
93
|
+
* @returns {Promise<{success: boolean, history: any[]}>}
|
|
94
|
+
*/
|
|
95
|
+
subscribeChannel(channel, callback, options) { return this._channels.subscribe(channel, callback, options) }
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} channel
|
|
98
|
+
* @returns {Promise<any>}
|
|
99
|
+
*/
|
|
100
|
+
unsubscribeChannel(channel) { return this._channels.unsubscribe(channel) }
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} channel
|
|
103
|
+
* @param {{limit?: number, since?: string}} [options]
|
|
104
|
+
* @returns {Promise<{success: boolean, history: any[]}>}
|
|
105
|
+
*/
|
|
106
|
+
getChannelHistory(channel, options) { return this._channels.getHistory(channel, options) }
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} roomName
|
|
110
|
+
* @param {(update: {roomName: string, present: string[], states: Object<string, any>, joined?: string, left?: string}) => void} callback
|
|
111
|
+
* @returns {Promise<{success: boolean, present: string[], states?: Object<string, any>}>}
|
|
112
|
+
*/
|
|
113
|
+
subscribePresence(roomName, callback) { return this._presence.subscribe(roomName, callback) }
|
|
114
|
+
/**
|
|
115
|
+
* @param {string} roomName
|
|
116
|
+
* @returns {Promise<boolean>}
|
|
117
|
+
*/
|
|
118
|
+
unsubscribePresence(roomName) { return this._presence.unsubscribe(roomName) }
|
|
119
|
+
/**
|
|
120
|
+
* @param {string} roomName
|
|
121
|
+
* @param {{state: any, expireAfter?: number, silent?: boolean}} options
|
|
122
|
+
* @returns {Promise<any>}
|
|
123
|
+
*/
|
|
124
|
+
publishPresenceState(roomName, options) { return this._presence.publishState(roomName, options) }
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} roomName
|
|
127
|
+
* @returns {Promise<any>}
|
|
128
|
+
*/
|
|
129
|
+
clearPresenceState(roomName) { return this._presence.clearState(roomName) }
|
|
130
|
+
/**
|
|
131
|
+
* @param {string} roomName
|
|
132
|
+
* @returns {Promise<boolean>}
|
|
133
|
+
*/
|
|
134
|
+
forcePresenceUpdate(roomName) { return this._presence.forceUpdate(roomName) }
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {string} collectionId
|
|
138
|
+
* @param {{onDiff?: (diff: {added: Array<{id: string, record: any}>, removed: Array<{id: string, record: any}>, changed: Array<{id: string, record: any}>, version: number}) => void}} [options]
|
|
139
|
+
* @returns {Promise<{success: boolean, ids: string[], records: any[], version: number}>}
|
|
140
|
+
*/
|
|
141
|
+
subscribeCollection(collectionId, options) { return this._collections.subscribe(collectionId, options) }
|
|
142
|
+
/**
|
|
143
|
+
* @param {string} collectionId
|
|
144
|
+
* @returns {Promise<boolean>}
|
|
145
|
+
*/
|
|
146
|
+
unsubscribeCollection(collectionId) { return this._collections.unsubscribe(collectionId) }
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} roomName
|
|
150
|
+
* @param {(update: {roomName: string, present: string[], states: Object<string, any>, joined?: string, left?: string}) => void} [onPresenceUpdate]
|
|
151
|
+
* @returns {Promise<{success: boolean, present: string[]}>}
|
|
152
|
+
*/
|
|
153
|
+
joinRoom(roomName, onPresenceUpdate) { return this._rooms.join(roomName, onPresenceUpdate) }
|
|
154
|
+
/**
|
|
155
|
+
* @param {string} roomName
|
|
156
|
+
* @returns {Promise<{success: boolean}>}
|
|
157
|
+
*/
|
|
158
|
+
leaveRoom(roomName) { return this._rooms.leave(roomName) }
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} roomName
|
|
161
|
+
* @returns {Promise<any>}
|
|
162
|
+
*/
|
|
163
|
+
getRoomMetadata(roomName) { return this._rooms.getMetadata(roomName) }
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {string} [connectionId] - if omitted, returns metadata for the current connection
|
|
167
|
+
* @returns {Promise<any>}
|
|
168
|
+
*/
|
|
169
|
+
async getConnectionMetadata(connectionId) {
|
|
170
|
+
try {
|
|
171
|
+
if (connectionId) {
|
|
172
|
+
const result = await this.command("mesh/get-connection-metadata", { connectionId })
|
|
173
|
+
return result.metadata
|
|
174
|
+
}
|
|
175
|
+
const result = await this.command("mesh/get-my-connection-metadata")
|
|
176
|
+
return result.metadata
|
|
177
|
+
} catch (error) {
|
|
178
|
+
clientLogger.error(`Failed to get metadata for connection:`, error)
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param {any} metadata
|
|
185
|
+
* @param {Object} [options]
|
|
186
|
+
* @returns {Promise<boolean>}
|
|
187
|
+
*/
|
|
188
|
+
async setConnectionMetadata(metadata, options) {
|
|
189
|
+
try {
|
|
190
|
+
const result = await this.command("mesh/set-my-connection-metadata", { metadata, options })
|
|
191
|
+
return result.success
|
|
192
|
+
} catch (error) {
|
|
193
|
+
clientLogger.error(`Failed to set metadata for connection:`, error)
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_setupConnectionEvents() {
|
|
199
|
+
this.connection.on("message", (data) => {
|
|
200
|
+
this.emit("message", data)
|
|
201
|
+
|
|
202
|
+
if (data.command === "mesh/record-update") this._records.handleUpdate(data.payload)
|
|
203
|
+
else if (data.command === "mesh/record-deleted") this._records.handleDeleted(data.payload)
|
|
204
|
+
else if (data.command === "mesh/presence-update") this._presence.handleUpdate(data.payload)
|
|
205
|
+
else if (data.command === "mesh/subscription-message") this._channels.handleMessage(data.payload)
|
|
206
|
+
else if (data.command === "mesh/collection-diff") this._collections.handleDiff(data.payload)
|
|
207
|
+
else {
|
|
208
|
+
const systemCommands = ["ping", "pong", "latency", "latency:request", "latency:response"]
|
|
209
|
+
if (data.command && !systemCommands.includes(data.command)) {
|
|
210
|
+
this.emit(data.command, data.payload)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
this.connection.on("close", () => {
|
|
216
|
+
this._status = Status.OFFLINE
|
|
217
|
+
this.emit("close")
|
|
218
|
+
this.reconnect()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
this.connection.on("error", (error) => this.emit("error", error))
|
|
222
|
+
this.connection.on("ping", () => { this._heartbeat(); this.emit("ping") })
|
|
223
|
+
this.connection.on("latency", (data) => this.emit("latency", data))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_setupVisibilityHandling() {
|
|
227
|
+
try {
|
|
228
|
+
this._isBrowser = !!globalThis.document && typeof globalThis.document.addEventListener === "function"
|
|
229
|
+
if (!this._isBrowser) return
|
|
230
|
+
|
|
231
|
+
setInterval(() => this._checkActivity(), 10000)
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const doc = globalThis.document
|
|
235
|
+
const events = ["mousedown", "keydown", "touchstart", "visibilitychange"]
|
|
236
|
+
events.forEach((eventName) => {
|
|
237
|
+
doc.addEventListener(eventName, () => {
|
|
238
|
+
this._lastActivityTime = Date.now()
|
|
239
|
+
if (eventName === "visibilitychange" && doc.visibilityState === "visible") {
|
|
240
|
+
if (this._status === Status.OFFLINE) return
|
|
241
|
+
this.command("mesh/noop", {}, 5000)
|
|
242
|
+
.then(() => { clientLogger.info("Tab visible, connection ok"); this.emit("republish") })
|
|
243
|
+
.catch(() => { clientLogger.info("Tab visible, forcing reconnect"); this._forceReconnect() })
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
} catch {}
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_checkActivity() {
|
|
252
|
+
if (!this._isBrowser) return
|
|
253
|
+
const now = Date.now()
|
|
254
|
+
const timeSinceActivity = now - this._lastActivityTime
|
|
255
|
+
if (timeSinceActivity > this.options.pingTimeout && this._status === Status.ONLINE) {
|
|
256
|
+
this.command("mesh/noop", {}, 5000).catch(() => {
|
|
257
|
+
clientLogger.info(`No activity for ${timeSinceActivity}ms, forcing reconnect`)
|
|
258
|
+
this._forceReconnect()
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
if (this._status === Status.ONLINE) this._lastActivityTime = now
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
_forceReconnect() {
|
|
265
|
+
if (this.isReconnecting) return
|
|
266
|
+
if (this.socket) { try { this.socket.close() } catch {} }
|
|
267
|
+
this._status = Status.OFFLINE
|
|
268
|
+
this.connection.socket = null
|
|
269
|
+
this.connection.status = Status.OFFLINE
|
|
270
|
+
this.reconnect()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** @returns {Promise<void>} */
|
|
274
|
+
connect() {
|
|
275
|
+
if (this._status === Status.ONLINE) return Promise.resolve()
|
|
276
|
+
|
|
277
|
+
if (this._status === Status.CONNECTING || this._status === Status.RECONNECTING) {
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
const onConnect = () => { this.removeListener("connect", onConnect); this.removeListener("error", onError); resolve() }
|
|
280
|
+
const onError = (error) => { this.removeListener("connect", onConnect); this.removeListener("error", onError); reject(error) }
|
|
281
|
+
this.once("connect", onConnect)
|
|
282
|
+
this.once("error", onError)
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
this._status = Status.CONNECTING
|
|
287
|
+
this._closed = false
|
|
288
|
+
return new Promise((resolve, reject) => {
|
|
289
|
+
try {
|
|
290
|
+
this.socket = new WebSocket(this.url)
|
|
291
|
+
this.socket.onopen = () => {
|
|
292
|
+
this._status = Status.ONLINE
|
|
293
|
+
this.connection.socket = this.socket
|
|
294
|
+
this.connection.status = Status.ONLINE
|
|
295
|
+
this.connection.applyListeners()
|
|
296
|
+
this._heartbeat()
|
|
297
|
+
|
|
298
|
+
if (this.connection.connectionId) {
|
|
299
|
+
this.emit("connect")
|
|
300
|
+
resolve()
|
|
301
|
+
} else {
|
|
302
|
+
const onId = () => { this.connection.removeListener("id-assigned", onId); this.emit("connect"); resolve() }
|
|
303
|
+
this.connection.once("id-assigned", onId)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
this.socket.onerror = () => {
|
|
307
|
+
this._status = Status.OFFLINE
|
|
308
|
+
reject(new CodeError("WebSocket connection error", "ECONNECTION", "ConnectionError"))
|
|
309
|
+
}
|
|
310
|
+
} catch (error) {
|
|
311
|
+
this._status = Status.OFFLINE
|
|
312
|
+
reject(error)
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
_heartbeat() {
|
|
318
|
+
this.missedPings = 0
|
|
319
|
+
if (!this.pingTimeout) {
|
|
320
|
+
this.pingTimeout = setTimeout(() => this._checkPingStatus(), this.options.pingTimeout)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
_checkPingStatus() {
|
|
325
|
+
this.missedPings++
|
|
326
|
+
if (this.missedPings > this.options.maxMissedPings) {
|
|
327
|
+
if (this.options.shouldReconnect) {
|
|
328
|
+
clientLogger.warn(`Missed ${this.missedPings} pings, reconnecting...`)
|
|
329
|
+
this.reconnect()
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
this.pingTimeout = setTimeout(() => this._checkPingStatus(), this.options.pingTimeout)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** @returns {Promise<void>} */
|
|
337
|
+
close() {
|
|
338
|
+
this._closed = true
|
|
339
|
+
if (this._status === Status.OFFLINE) return Promise.resolve()
|
|
340
|
+
|
|
341
|
+
return new Promise((resolve) => {
|
|
342
|
+
const onClose = () => {
|
|
343
|
+
this.removeListener("close", onClose)
|
|
344
|
+
this._status = Status.OFFLINE
|
|
345
|
+
this.emit("disconnect")
|
|
346
|
+
resolve()
|
|
347
|
+
}
|
|
348
|
+
this.once("close", onClose)
|
|
349
|
+
clearTimeout(this.pingTimeout)
|
|
350
|
+
this.pingTimeout = undefined
|
|
351
|
+
if (this.socket) this.socket.close()
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
reconnect() {
|
|
356
|
+
if (this._closed || !this.options.shouldReconnect || this.isReconnecting) return
|
|
357
|
+
|
|
358
|
+
this._status = Status.RECONNECTING
|
|
359
|
+
this.isReconnecting = true
|
|
360
|
+
clearTimeout(this.pingTimeout)
|
|
361
|
+
this.pingTimeout = undefined
|
|
362
|
+
this.missedPings = 0
|
|
363
|
+
|
|
364
|
+
if (this.socket) {
|
|
365
|
+
try { this.socket.close() } catch {}
|
|
366
|
+
this.emit("disconnect")
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let attempt = 1
|
|
370
|
+
const connect = () => {
|
|
371
|
+
this.socket = new WebSocket(this.url)
|
|
372
|
+
this.socket.onerror = () => {
|
|
373
|
+
attempt++
|
|
374
|
+
if (attempt <= this.options.maxReconnectAttempts) {
|
|
375
|
+
setTimeout(connect, this.options.reconnectInterval)
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
this.isReconnecting = false
|
|
379
|
+
this._status = Status.OFFLINE
|
|
380
|
+
this.emit("reconnectfailed")
|
|
381
|
+
}
|
|
382
|
+
this.socket.onopen = () => {
|
|
383
|
+
this.isReconnecting = false
|
|
384
|
+
this._status = Status.ONLINE
|
|
385
|
+
this.connection.socket = this.socket
|
|
386
|
+
this.connection.status = Status.ONLINE
|
|
387
|
+
this.connection.applyListeners(true)
|
|
388
|
+
this._heartbeat()
|
|
389
|
+
|
|
390
|
+
const finish = async () => {
|
|
391
|
+
await this._resubscribeAll()
|
|
392
|
+
this.emit("connect")
|
|
393
|
+
this.emit("reconnect")
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (this.connection.connectionId) {
|
|
397
|
+
finish()
|
|
398
|
+
} else {
|
|
399
|
+
const onId = () => { this.connection.removeListener("id-assigned", onId); finish() }
|
|
400
|
+
this.connection.once("id-assigned", onId)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
connect()
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* @param {string} command - command name
|
|
409
|
+
* @param {Object} [payload] - command payload
|
|
410
|
+
* @param {number} [expiresIn] - timeout in ms (default 30000)
|
|
411
|
+
* @returns {Promise<any>}
|
|
412
|
+
*/
|
|
413
|
+
async command(command, payload, expiresIn = 30000) {
|
|
414
|
+
if (this._status !== Status.ONLINE) {
|
|
415
|
+
return this.connect().then(() => this.connection.command(command, payload, expiresIn))
|
|
416
|
+
}
|
|
417
|
+
return this.connection.command(command, payload, expiresIn)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async _resubscribeAll() {
|
|
421
|
+
clientLogger.info("Resubscribing to all subscriptions after reconnect")
|
|
422
|
+
try {
|
|
423
|
+
const successfulRooms = await this._rooms.resubscribe()
|
|
424
|
+
await Promise.allSettled([
|
|
425
|
+
...Array.from(this._records.resubscribe()),
|
|
426
|
+
...Array.from(this._channels.resubscribe()),
|
|
427
|
+
...Array.from(this._collections.resubscribe()),
|
|
428
|
+
].flat())
|
|
429
|
+
|
|
430
|
+
if (successfulRooms.length > 0) {
|
|
431
|
+
for (const roomName of successfulRooms) {
|
|
432
|
+
try { await this._presence.forceUpdate(roomName) }
|
|
433
|
+
catch (err) { clientLogger.error(`Error refreshing presence for room ${roomName}:`, err) }
|
|
434
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} catch (error) {
|
|
438
|
+
clientLogger.error("Error during resubscription:", error)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { EventEmitter } from "eventemitter3";
|
|
2
|
+
import { CodeError, parseCommand, Status, stringifyCommand } from "../shared/index.js";
|
|
3
|
+
import { IdManager } from "./ids.js";
|
|
4
|
+
import { Queue } from "./queue.js";
|
|
5
|
+
|
|
6
|
+
export class Connection extends EventEmitter {
|
|
7
|
+
socket = null;
|
|
8
|
+
ids = new IdManager();
|
|
9
|
+
queue = new Queue();
|
|
10
|
+
callbacks = {};
|
|
11
|
+
status = Status.OFFLINE;
|
|
12
|
+
connectionId;
|
|
13
|
+
|
|
14
|
+
/** @param {WebSocket|null} socket */
|
|
15
|
+
constructor(socket) {
|
|
16
|
+
super();
|
|
17
|
+
this.socket = socket;
|
|
18
|
+
if (socket) {
|
|
19
|
+
this.applyListeners();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get isDead() {
|
|
24
|
+
return !this.socket || this.socket.readyState !== WebSocket.OPEN;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {{id?: number, command: string, payload?: Object}} command
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
send(command) {
|
|
32
|
+
try {
|
|
33
|
+
if (!this.isDead) {
|
|
34
|
+
this.socket?.send(stringifyCommand(command));
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{id?: number, command: string, payload?: Object}} command
|
|
45
|
+
* @param {number} [expiresIn] - queue expiry in ms
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
sendWithQueue(command, expiresIn) {
|
|
49
|
+
const success = this.send(command);
|
|
50
|
+
|
|
51
|
+
if (!success) {
|
|
52
|
+
this.queue.add(command, expiresIn);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return success;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {boolean} [reconnection] - if true, drains the queued commands
|
|
60
|
+
* @returns {void}
|
|
61
|
+
*/
|
|
62
|
+
applyListeners(reconnection = false) {
|
|
63
|
+
if (!this.socket) return;
|
|
64
|
+
|
|
65
|
+
const drainQueue = () => {
|
|
66
|
+
while (!this.queue.isEmpty) {
|
|
67
|
+
const item = this.queue.pop();
|
|
68
|
+
if (item) {
|
|
69
|
+
this.send(item.value);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (reconnection) {
|
|
75
|
+
drainQueue();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.socket.onclose = () => {
|
|
79
|
+
this.status = Status.OFFLINE;
|
|
80
|
+
this.emit("close");
|
|
81
|
+
this.emit("disconnect");
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
this.socket.onerror = (error) => {
|
|
85
|
+
this.emit("error", error);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
this.socket.onmessage = (event) => {
|
|
89
|
+
try {
|
|
90
|
+
const data = parseCommand(event.data);
|
|
91
|
+
|
|
92
|
+
this.emit("message", data);
|
|
93
|
+
|
|
94
|
+
if (data.command === "mesh/assign-id") {
|
|
95
|
+
this.connectionId = data.payload;
|
|
96
|
+
this.emit("id-assigned", data.payload);
|
|
97
|
+
} else if (data.command === "latency:request") {
|
|
98
|
+
this.emit("latency:request", data.payload);
|
|
99
|
+
this.command("latency:response", data.payload, null);
|
|
100
|
+
} else if (data.command === "latency") {
|
|
101
|
+
this.emit("latency", data.payload);
|
|
102
|
+
} else if (data.command === "ping") {
|
|
103
|
+
this.emit("ping");
|
|
104
|
+
this.command("pong", {}, null);
|
|
105
|
+
} else {
|
|
106
|
+
this.emit(data.command, data.payload);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (data.id !== undefined && this.callbacks[data.id]) {
|
|
110
|
+
this.callbacks[data.id](data.payload);
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
this.emit("error", error);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string} command - command name
|
|
120
|
+
* @param {Object} [payload] - command payload
|
|
121
|
+
* @param {number|null} [expiresIn] - timeout in ms, null to fire-and-forget (default 30000)
|
|
122
|
+
* @param {(result: any, error?: Error) => void} [callback] - optional node-style callback
|
|
123
|
+
* @returns {Promise<any>}
|
|
124
|
+
*/
|
|
125
|
+
command(command, payload, expiresIn = 30_000, callback) {
|
|
126
|
+
const id = this.ids.reserve();
|
|
127
|
+
const cmd = { id, command, payload: payload ?? {} };
|
|
128
|
+
|
|
129
|
+
this.sendWithQueue(cmd, expiresIn || 30000);
|
|
130
|
+
|
|
131
|
+
if (expiresIn === null) {
|
|
132
|
+
this.ids.release(id);
|
|
133
|
+
return Promise.resolve();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let timer
|
|
137
|
+
|
|
138
|
+
const responsePromise = new Promise((resolve, reject) => {
|
|
139
|
+
this.callbacks[id] = (result, error) => {
|
|
140
|
+
clearTimeout(timer)
|
|
141
|
+
this.ids.release(id);
|
|
142
|
+
delete this.callbacks[id];
|
|
143
|
+
if (error) reject(error)
|
|
144
|
+
else resolve(result)
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
149
|
+
timer = setTimeout(() => {
|
|
150
|
+
if (!this.callbacks[id]) return;
|
|
151
|
+
this.ids.release(id);
|
|
152
|
+
delete this.callbacks[id];
|
|
153
|
+
reject(new CodeError(`Command timed out after ${expiresIn}ms.`, "ETIMEOUT", "TimeoutError"));
|
|
154
|
+
}, expiresIn);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (typeof callback === "function") {
|
|
158
|
+
Promise.race([responsePromise, timeoutPromise])
|
|
159
|
+
.then((result) => callback(result))
|
|
160
|
+
.catch((error) => callback(null, error));
|
|
161
|
+
return responsePromise;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return Promise.race([responsePromise, timeoutPromise]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** @returns {boolean} */
|
|
168
|
+
close() {
|
|
169
|
+
if (this.isDead) return false;
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
this.socket?.close();
|
|
173
|
+
return true;
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class IdManager {
|
|
2
|
+
usedIds = new Set();
|
|
3
|
+
counter = 0;
|
|
4
|
+
maxCounter;
|
|
5
|
+
|
|
6
|
+
constructor(maxCounter = 9999) {
|
|
7
|
+
this.maxCounter = maxCounter;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
release(id) {
|
|
11
|
+
this.usedIds.delete(id);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
reserve() {
|
|
15
|
+
this.counter = (this.counter + 1) % this.maxCounter;
|
|
16
|
+
const timestamp = Date.now() % 10000;
|
|
17
|
+
let id = timestamp * 10000 + this.counter;
|
|
18
|
+
|
|
19
|
+
while (this.usedIds.has(id)) {
|
|
20
|
+
id = (id + 1) % (10000 * 10000);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
this.usedIds.add(id);
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export class QueueItem {
|
|
2
|
+
value;
|
|
3
|
+
_expiration;
|
|
4
|
+
|
|
5
|
+
constructor(value, expiresIn) {
|
|
6
|
+
this.value = value;
|
|
7
|
+
this._expiration = Date.now() + expiresIn;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
get expiresIn() {
|
|
11
|
+
return this._expiration - Date.now();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get isExpired() {
|
|
15
|
+
return Date.now() > this._expiration;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class Queue {
|
|
20
|
+
items = [];
|
|
21
|
+
|
|
22
|
+
add(item, expiresIn) {
|
|
23
|
+
this.items.push(new QueueItem(item, expiresIn));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get isEmpty() {
|
|
27
|
+
this.items = this.items.filter((item) => !item.isExpired);
|
|
28
|
+
return this.items.length === 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
pop() {
|
|
32
|
+
while (this.items.length > 0) {
|
|
33
|
+
const item = this.items.shift();
|
|
34
|
+
if (item && !item.isExpired) {
|
|
35
|
+
return item;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
clear() {
|
|
42
|
+
this.items = [];
|
|
43
|
+
}
|
|
44
|
+
}
|