@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,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,5 @@
1
+ export { RealtimeClient } from "./client.js"
2
+ export { Connection } from "./connection.js"
3
+
4
+ import fjp from "fast-json-patch"
5
+ export const { applyPatch } = fjp
@@ -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
+ }