relayx-webjs 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/CHANGELOG.md +6 -0
- package/LICENSE +201 -0
- package/README.md +186 -0
- package/example/README.md +82 -0
- package/example/chat-app/index.html +13 -0
- package/example/chat-app/package-lock.json +3105 -0
- package/example/chat-app/package.json +24 -0
- package/example/chat-app/postcss.config.js +6 -0
- package/example/chat-app/src/App.jsx +193 -0
- package/example/chat-app/src/components/ui/button.jsx +16 -0
- package/example/chat-app/src/components/ui/card.jsx +9 -0
- package/example/chat-app/src/components/ui/input.jsx +11 -0
- package/example/chat-app/src/index.css +3 -0
- package/example/chat-app/src/main.jsx +10 -0
- package/example/chat-app/tailwind.config.js +8 -0
- package/example/chat-app/vite.config.js +12 -0
- package/example/screenshots/screen_1.png +0 -0
- package/example/screenshots/screen_2.png +0 -0
- package/example/screenshots/screen_3.png +0 -0
- package/package.json +34 -0
- package/realtime/realtime.js +936 -0
- package/tests/load.js +101 -0
- package/tests/test.js +514 -0
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
import { connect, JSONCodec, Events, DebugEvents, AckPolicy, ReplayPolicy, credsAuthenticator } from "nats.ws";
|
|
2
|
+
import { DeliverPolicy, jetstream } from "@nats-io/jetstream";
|
|
3
|
+
import { encode, decode } from "@msgpack/msgpack";
|
|
4
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
5
|
+
|
|
6
|
+
export class Realtime {
|
|
7
|
+
|
|
8
|
+
#baseUrl = "";
|
|
9
|
+
|
|
10
|
+
#natsClient = null;
|
|
11
|
+
#codec = JSONCodec();
|
|
12
|
+
#jetstream = null;
|
|
13
|
+
#consumerMap = {};
|
|
14
|
+
|
|
15
|
+
#event_func = {};
|
|
16
|
+
#topicMap = [];
|
|
17
|
+
|
|
18
|
+
// Status Codes
|
|
19
|
+
#RECONNECTING = "RECONNECTING";
|
|
20
|
+
#RECONNECTED = "RECONNECTED";
|
|
21
|
+
#RECONN_FAIL = "RECONN_FAIL";
|
|
22
|
+
|
|
23
|
+
setRemoteUserAttempts = 0;
|
|
24
|
+
setRemoteUserRetries = 5;
|
|
25
|
+
|
|
26
|
+
// Retry attempts end
|
|
27
|
+
reconnected = false;
|
|
28
|
+
disconnected = true;
|
|
29
|
+
reconnecting = false;
|
|
30
|
+
connected = false;
|
|
31
|
+
|
|
32
|
+
// Offline messages
|
|
33
|
+
#offlineMessageBuffer = [];
|
|
34
|
+
|
|
35
|
+
// Latency
|
|
36
|
+
#latency = [];
|
|
37
|
+
#latencyPush = null;
|
|
38
|
+
#isSendingLatency = false;
|
|
39
|
+
|
|
40
|
+
#maxPublishRetries = 5;
|
|
41
|
+
|
|
42
|
+
constructor(config){
|
|
43
|
+
if(typeof config != "object"){
|
|
44
|
+
throw new Error("Realtime($config). $config not object => {}")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if(config != null && config != undefined){
|
|
48
|
+
this.api_key = config.api_key != undefined ? config.api_key : null;
|
|
49
|
+
this.secret = config.secret != undefined ? config.secret : null;
|
|
50
|
+
|
|
51
|
+
if(this.api_key == null){
|
|
52
|
+
throw new Error("api_key value null")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if(this.secret == null){
|
|
56
|
+
throw new Error("secret value null")
|
|
57
|
+
}
|
|
58
|
+
}else{
|
|
59
|
+
throw new Error("{api_key: <value>, secret: <value>} not passed in constructor")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.namespace = null;
|
|
63
|
+
this.topicHash = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
Initializes library with configuration options.
|
|
68
|
+
*/
|
|
69
|
+
async init(staging, opts){
|
|
70
|
+
/**
|
|
71
|
+
* Method can take in 2 variables
|
|
72
|
+
* @param{boolean} staging - Sets URL to staging or production URL
|
|
73
|
+
* @param{Object} opts - Library configuration options
|
|
74
|
+
*/
|
|
75
|
+
var len = arguments.length;
|
|
76
|
+
|
|
77
|
+
if (len > 2){
|
|
78
|
+
new Error("Method takes only 2 variables, " + len + " given");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (len == 2){
|
|
82
|
+
if(typeof arguments[0] == "boolean"){
|
|
83
|
+
staging = arguments[0];
|
|
84
|
+
}else{
|
|
85
|
+
staging = false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if(arguments[1] instanceof Object){
|
|
89
|
+
opts = arguments[1];
|
|
90
|
+
}else{
|
|
91
|
+
opts = {};
|
|
92
|
+
}
|
|
93
|
+
}else if(len == 1){
|
|
94
|
+
if(arguments[0] instanceof Object){
|
|
95
|
+
opts = arguments[0];
|
|
96
|
+
staging = false;
|
|
97
|
+
}else{
|
|
98
|
+
opts = {};
|
|
99
|
+
staging = arguments[0];
|
|
100
|
+
this.#log(staging)
|
|
101
|
+
}
|
|
102
|
+
}else{
|
|
103
|
+
staging = false;
|
|
104
|
+
opts = {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.staging = staging;
|
|
108
|
+
this.opts = opts;
|
|
109
|
+
|
|
110
|
+
if (staging !== undefined || staging !== null){
|
|
111
|
+
this.#baseUrl = staging ? [
|
|
112
|
+
"nats://0.0.0.0:4421",
|
|
113
|
+
"nats://0.0.0.0:4422",
|
|
114
|
+
"nats://0.0.0.0:4423"
|
|
115
|
+
] :
|
|
116
|
+
[
|
|
117
|
+
`wss://api.relay-x.io:4421`,
|
|
118
|
+
`wss://api.relay-x.io:4422`,
|
|
119
|
+
`wss://api.relay-x.io:4423`
|
|
120
|
+
];
|
|
121
|
+
}else{
|
|
122
|
+
this.#baseUrl = [
|
|
123
|
+
`wss://api.relay-x.io:4421`,
|
|
124
|
+
`wss://api.relay-x.io:4422`,
|
|
125
|
+
`wss://api.relay-x.io:4423`
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.#log(this.#baseUrl);
|
|
130
|
+
this.#log(opts);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Gets the namespace of the user using a micro service
|
|
135
|
+
* @returns {string} namespace value. Null if failed to retreive
|
|
136
|
+
*/
|
|
137
|
+
async #getNameSpace() {
|
|
138
|
+
var res = await this.#natsClient.request("accounts.user.get_namespace",
|
|
139
|
+
this.#codec.encode({
|
|
140
|
+
"api_key": this.api_key
|
|
141
|
+
}),
|
|
142
|
+
{
|
|
143
|
+
timeout: 5000
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
var data = res.json()
|
|
148
|
+
|
|
149
|
+
this.#log(data)
|
|
150
|
+
|
|
151
|
+
if(data["status"] == "NAMESPACE_RETRIEVE_SUCCESS"){
|
|
152
|
+
this.namespace = data["data"]["namespace"]
|
|
153
|
+
this.topicHash = data["data"]["hash"]
|
|
154
|
+
}else{
|
|
155
|
+
this.namespace = null;
|
|
156
|
+
this.topicHash = null;
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Connects to the relay network
|
|
164
|
+
*/
|
|
165
|
+
async connect(){
|
|
166
|
+
this.SEVER_URL = this.#baseUrl;
|
|
167
|
+
|
|
168
|
+
var credsFile = this.#getUserCreds(this.api_key, this.secret)
|
|
169
|
+
credsFile = new TextEncoder().encode(credsFile);
|
|
170
|
+
var credsAuth = credsAuthenticator(credsFile);
|
|
171
|
+
|
|
172
|
+
try{
|
|
173
|
+
this.#natsClient = await connect({
|
|
174
|
+
servers: this.SEVER_URL,
|
|
175
|
+
noEcho: true,
|
|
176
|
+
reconnect: true,
|
|
177
|
+
maxReconnectAttempts: 1200,
|
|
178
|
+
reconnectTimeWait: 1000,
|
|
179
|
+
authenticator: credsAuth,
|
|
180
|
+
token: this.api_key,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
this.#jetstream = await jetstream(this.#natsClient);
|
|
184
|
+
|
|
185
|
+
await this.#getNameSpace()
|
|
186
|
+
|
|
187
|
+
this.connected = true;
|
|
188
|
+
}catch(err){
|
|
189
|
+
this.#log("ERR")
|
|
190
|
+
this.#log(err);
|
|
191
|
+
|
|
192
|
+
this.connected = false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (this.connected == true){
|
|
196
|
+
this.#log("Connected to server!");
|
|
197
|
+
|
|
198
|
+
// Callback on client side
|
|
199
|
+
if (CONNECTED in this.#event_func){
|
|
200
|
+
if (this.#event_func[CONNECTED] !== null || this.#event_func[CONNECTED] !== undefined){
|
|
201
|
+
this.#event_func[CONNECTED]()
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
this.#natsClient.closed().then(() => {
|
|
206
|
+
this.#log("the connection closed!");
|
|
207
|
+
|
|
208
|
+
this.#offlineMessageBuffer.length = 0;
|
|
209
|
+
|
|
210
|
+
if (DISCONNECTED in this.#event_func){
|
|
211
|
+
if (this.#event_func[DISCONNECTED] !== null || this.#event_func[DISCONNECTED] !== undefined){
|
|
212
|
+
this.#event_func[DISCONNECTED]()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
(async () => {
|
|
218
|
+
for await (const s of this.#natsClient.status()) {
|
|
219
|
+
this.#log(s.type)
|
|
220
|
+
|
|
221
|
+
switch (s.type) {
|
|
222
|
+
case Events.Disconnect:
|
|
223
|
+
this.#log(`client disconnected - ${s.data}`);
|
|
224
|
+
|
|
225
|
+
this.connected = false;
|
|
226
|
+
this.#consumerMap = {};
|
|
227
|
+
|
|
228
|
+
if (DISCONNECTED in this.#event_func){
|
|
229
|
+
if (this.#event_func[DISCONNECTED] !== null || this.#event_func[DISCONNECTED] !== undefined){
|
|
230
|
+
this.#event_func[DISCONNECTED]()
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
case Events.LDM:
|
|
235
|
+
this.#log("client has been requested to reconnect");
|
|
236
|
+
break;
|
|
237
|
+
case Events.Update:
|
|
238
|
+
this.#log(`client received a cluster update - `);
|
|
239
|
+
this.#log(s.data)
|
|
240
|
+
break;
|
|
241
|
+
case Events.Reconnect:
|
|
242
|
+
this.#log(`client reconnected -`);
|
|
243
|
+
this.#log(s.data)
|
|
244
|
+
|
|
245
|
+
this.reconnecting = false;
|
|
246
|
+
this.connected = true;
|
|
247
|
+
|
|
248
|
+
if(RECONNECT in this.#event_func){
|
|
249
|
+
this.#event_func[RECONNECT](this.#RECONNECTED);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Resend any messages sent while client was offline
|
|
253
|
+
this.#publishMessagesOnReconnect();
|
|
254
|
+
break;
|
|
255
|
+
case Events.Error:
|
|
256
|
+
this.#log("client got a permissions error");
|
|
257
|
+
break;
|
|
258
|
+
case DebugEvents.Reconnecting:
|
|
259
|
+
this.#log("client is attempting to reconnect");
|
|
260
|
+
|
|
261
|
+
this.reconnecting = true;
|
|
262
|
+
|
|
263
|
+
if(RECONNECT in this.#event_func && this.reconnecting){
|
|
264
|
+
this.#event_func[RECONNECT](this.#RECONNECTING);
|
|
265
|
+
}
|
|
266
|
+
break;
|
|
267
|
+
case DebugEvents.StaleConnection:
|
|
268
|
+
this.#log("client has a stale connection");
|
|
269
|
+
break;
|
|
270
|
+
default:
|
|
271
|
+
this.#log(`got an unknown status ${s.type}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
})().then();
|
|
275
|
+
|
|
276
|
+
// Subscribe to topics
|
|
277
|
+
this.#subscribeToTopics();
|
|
278
|
+
this.#log("Subscribed to topics");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Closes connection
|
|
284
|
+
*/
|
|
285
|
+
close(){
|
|
286
|
+
if(this.#natsClient !== null){
|
|
287
|
+
this.reconnected = false;
|
|
288
|
+
this.disconnected = true;
|
|
289
|
+
|
|
290
|
+
this.#offlineMessageBuffer.length = 0;
|
|
291
|
+
|
|
292
|
+
this.#natsClient.close();
|
|
293
|
+
}else{
|
|
294
|
+
this.#log("Null / undefined socket, cannot close connection");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Start consumers for topics initialized by user
|
|
300
|
+
*/
|
|
301
|
+
async #subscribeToTopics(){
|
|
302
|
+
this.#topicMap.forEach(async (topic) => {
|
|
303
|
+
// Subscribe to stream
|
|
304
|
+
await this.#startConsumer(topic);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Deletes reference to user defined event callback.
|
|
310
|
+
* This will stop listening to a topic
|
|
311
|
+
* @param {string} topic
|
|
312
|
+
* @returns {boolean} - To check if topic unsubscribe was successful
|
|
313
|
+
*/
|
|
314
|
+
async off(topic){
|
|
315
|
+
if(topic == null || topic == undefined){
|
|
316
|
+
throw new Error("$topic is null / undefined")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if(typeof topic !== "string"){
|
|
320
|
+
throw new Error(`Expected $topic type -> string. Instead receieved -> ${typeof topic}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this.#topicMap = this.#topicMap.filter(item => item !== topic);
|
|
324
|
+
|
|
325
|
+
delete this.#event_func[topic];
|
|
326
|
+
|
|
327
|
+
return await this.#deleteConsumer(topic);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Subscribes to a topic
|
|
332
|
+
* @param {string} topic - Name of the event
|
|
333
|
+
* @param {function} func - Callback function to call on user thread
|
|
334
|
+
* @returns {boolean} - To check if topic subscription was successful
|
|
335
|
+
*/
|
|
336
|
+
async on(topic, func){
|
|
337
|
+
if(topic == null || topic == undefined){
|
|
338
|
+
throw new Error("$topic is null / undefined")
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if(func == null || func == undefined){
|
|
342
|
+
throw new Error("$func is null / undefined")
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if ((typeof func !== "function")){
|
|
346
|
+
throw new Error(`Expected $listener type -> function. Instead receieved -> ${typeof func}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if(typeof topic !== "string"){
|
|
350
|
+
throw new Error(`Expected $topic type -> string. Instead receieved -> ${typeof topic}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if(!(topic in this.#event_func)){
|
|
354
|
+
this.#event_func[topic] = func;
|
|
355
|
+
}else{
|
|
356
|
+
return false
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (![CONNECTED, DISCONNECTED, RECONNECT, this.#RECONNECTED,
|
|
360
|
+
this.#RECONNECTING, this.#RECONN_FAIL, MESSAGE_RESEND, SERVER_DISCONNECT].includes(topic)){
|
|
361
|
+
if(!this.isTopicValid(topic)){
|
|
362
|
+
// We have an invalid topic, lets remove it
|
|
363
|
+
if(topic in this.#event_func){
|
|
364
|
+
delete this.#event_func[topic];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
throw new Error("Invalid topic, use isTopicValid($topic) to validate topic")
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if(!this.#topicMap.includes(topic)){
|
|
371
|
+
this.#topicMap.push(topic);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if(this.connected){
|
|
375
|
+
// Connected we need to create a topic in a stream
|
|
376
|
+
await this.#startConsumer(topic);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A method to send a message to a topic.
|
|
385
|
+
* Retry methods included. Stores messages in an array if offline.
|
|
386
|
+
* @param {string} topic - Name of the event
|
|
387
|
+
* @param {object} data - Data to send
|
|
388
|
+
* @returns
|
|
389
|
+
*/
|
|
390
|
+
async publish(topic, data){
|
|
391
|
+
if(topic == null || topic == undefined){
|
|
392
|
+
throw new Error("$topic is null or undefined");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if(topic == ""){
|
|
396
|
+
throw new Error("$topic cannot be an empty string")
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if(typeof topic !== "string"){
|
|
400
|
+
throw new Error(`Expected $topic type -> string. Instead receieved -> ${typeof topic}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if(!this.isTopicValid(topic)){
|
|
404
|
+
throw new Error("Invalid topic, use isTopicValid($topic) to validate topic")
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if(!this.#isMessageValid(data)){
|
|
408
|
+
throw new Error("$message must be JSON, string or number")
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
var start = Date.now()
|
|
412
|
+
var messageId = crypto.randomUUID();
|
|
413
|
+
|
|
414
|
+
var message = {
|
|
415
|
+
"client_id": this.#getClientId(),
|
|
416
|
+
"id": messageId,
|
|
417
|
+
"room": topic,
|
|
418
|
+
"message": data,
|
|
419
|
+
"start": Date.now()
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
this.#log("Encoding message via msg pack...")
|
|
423
|
+
var encodedMessage = encode(message);
|
|
424
|
+
|
|
425
|
+
if(this.connected){
|
|
426
|
+
if(!this.#topicMap.includes(topic)){
|
|
427
|
+
this.#topicMap.push(topic);
|
|
428
|
+
}else{
|
|
429
|
+
this.#log(`${topic} exists locally, moving on...`)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
this.#log(`Publishing to topic => ${this.#getStreamTopic(topic)}`)
|
|
433
|
+
|
|
434
|
+
const ack = await this.#jetstream.publish(this.#getStreamTopic(topic), encodedMessage);
|
|
435
|
+
this.#log(`Publish Ack =>`)
|
|
436
|
+
this.#log(ack)
|
|
437
|
+
|
|
438
|
+
var latency = Date.now() - start;
|
|
439
|
+
this.#log(`Latency => ${latency} ms`);
|
|
440
|
+
|
|
441
|
+
return ack !== null && ack !== undefined;
|
|
442
|
+
}else{
|
|
443
|
+
this.#offlineMessageBuffer.push({
|
|
444
|
+
topic: topic,
|
|
445
|
+
message: data
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Starts consumer for particular topic if stream exists
|
|
454
|
+
* @param {string} topic
|
|
455
|
+
*/
|
|
456
|
+
async history(topic, start, end){
|
|
457
|
+
this.#log(start)
|
|
458
|
+
if(topic == null || topic == undefined){
|
|
459
|
+
throw new Error("$topic is null or undefined");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if(topic == ""){
|
|
463
|
+
throw new Error("$topic cannot be an empty string")
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if(typeof topic !== "string"){
|
|
467
|
+
throw new Error(`Expected $topic type -> string. Instead receieved -> ${typeof topic}`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if(start == undefined || start == null){
|
|
471
|
+
throw new Error(`$start must be provided. $start is => ${start}`)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if(!(start instanceof Date)){
|
|
475
|
+
throw new Error(`$start must be a Date object`)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if(end != undefined && end != null){
|
|
479
|
+
if(!(end instanceof Date)){
|
|
480
|
+
throw new Error(`$end must be a Date object`)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if(start > end){
|
|
484
|
+
throw new Error("$start is greater than $end, must be before $end")
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
end = end.toISOString();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
var opts = {
|
|
491
|
+
name: `${topic}_${uuidv4()}_history`,
|
|
492
|
+
filter_subjects: [this.#getStreamTopic(topic)],
|
|
493
|
+
replay_policy: ReplayPolicy.Instant,
|
|
494
|
+
opt_start_time: start,
|
|
495
|
+
ack_policy: AckPolicy.Explicit,
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const consumer = await this.#jetstream.consumers.get(this.#getStreamName(), opts);
|
|
499
|
+
this.#log(this.#topicMap)
|
|
500
|
+
this.#log("Consumer is consuming");
|
|
501
|
+
|
|
502
|
+
var history = [];
|
|
503
|
+
|
|
504
|
+
while(true){
|
|
505
|
+
var msg = await consumer.next({
|
|
506
|
+
expires: 1000
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
if(msg == null){
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if(end != null || end != undefined){
|
|
514
|
+
if(msg.timestamp > end){
|
|
515
|
+
break
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
this.#log("Decoding msgpack message...")
|
|
520
|
+
var data = decode(msg.data);
|
|
521
|
+
this.#log(data);
|
|
522
|
+
|
|
523
|
+
history.push(data.message);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
var del = await consumer.delete();
|
|
527
|
+
|
|
528
|
+
this.#log("History pull done: " + del);
|
|
529
|
+
|
|
530
|
+
return history;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Method resends messages when the client successfully connects to the
|
|
535
|
+
* server again
|
|
536
|
+
* @returns - Array of success and failure messages
|
|
537
|
+
*/
|
|
538
|
+
async #publishMessagesOnReconnect(){
|
|
539
|
+
var messageSentStatus = [];
|
|
540
|
+
|
|
541
|
+
for(let i = 0; i < this.#offlineMessageBuffer.length; i++){
|
|
542
|
+
let data = this.#offlineMessageBuffer[i];
|
|
543
|
+
|
|
544
|
+
const topic = data.topic;
|
|
545
|
+
const message = data.message;
|
|
546
|
+
|
|
547
|
+
const output = await this.publish(topic, message);
|
|
548
|
+
|
|
549
|
+
messageSentStatus.push({
|
|
550
|
+
topic: topic,
|
|
551
|
+
message: message,
|
|
552
|
+
resent: output
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Clearing out offline messages
|
|
557
|
+
this.#offlineMessageBuffer.length = 0;
|
|
558
|
+
|
|
559
|
+
// Send to client
|
|
560
|
+
if(MESSAGE_RESEND in this.#event_func && messageSentStatus.length > 0){
|
|
561
|
+
this.#event_func[MESSAGE_RESEND](messageSentStatus);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Room functions
|
|
566
|
+
/**
|
|
567
|
+
* Starts consumer for particular topic if stream exists
|
|
568
|
+
* @param {string} topic
|
|
569
|
+
*/
|
|
570
|
+
async #startConsumer(topic){
|
|
571
|
+
this.#log(`Starting consumer for topic: ${topic}_${uuidv4()}`)
|
|
572
|
+
|
|
573
|
+
var opts = {
|
|
574
|
+
name: `${topic}_${uuidv4()}`,
|
|
575
|
+
filter_subjects: [this.#getStreamTopic(topic), this.#getStreamTopic(topic) + "_presence"],
|
|
576
|
+
replay_policy: ReplayPolicy.Instant,
|
|
577
|
+
opt_start_time: new Date(),
|
|
578
|
+
ack_policy: AckPolicy.Explicit,
|
|
579
|
+
delivery_policy: DeliverPolicy.New
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const consumer = await this.#jetstream.consumers.get(this.#getStreamName(), opts);
|
|
583
|
+
this.#log(this.#topicMap)
|
|
584
|
+
|
|
585
|
+
this.#consumerMap[topic] = consumer;
|
|
586
|
+
|
|
587
|
+
await consumer.consume({
|
|
588
|
+
callback: async (msg) => {
|
|
589
|
+
try{
|
|
590
|
+
const now = Date.now();
|
|
591
|
+
this.#log("Decoding msgpack message...")
|
|
592
|
+
var data = decode(msg.data);
|
|
593
|
+
|
|
594
|
+
var room = data.room;
|
|
595
|
+
|
|
596
|
+
this.#log(data);
|
|
597
|
+
|
|
598
|
+
// Push topic message to main thread
|
|
599
|
+
if (room in this.#event_func && data.client_id != this.#getClientId()){
|
|
600
|
+
this.#event_func[room]({
|
|
601
|
+
"id": data.id,
|
|
602
|
+
"data": data.message
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
msg.ack();
|
|
607
|
+
|
|
608
|
+
await this.#logLatency(now, data);
|
|
609
|
+
}catch(err){
|
|
610
|
+
this.#log("Consumer err " + err);
|
|
611
|
+
msg.nack(5000);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
this.#log("Consumer is consuming");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Deletes consumer
|
|
620
|
+
* @param {string} topic
|
|
621
|
+
*/
|
|
622
|
+
async #deleteConsumer(topic){
|
|
623
|
+
const consumer = this.#consumerMap[topic]
|
|
624
|
+
|
|
625
|
+
var del = false;
|
|
626
|
+
|
|
627
|
+
if (consumer != null && consumer != undefined){
|
|
628
|
+
del = await consumer.delete();
|
|
629
|
+
}else{
|
|
630
|
+
del = false
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
delete this.#consumerMap[topic];
|
|
634
|
+
|
|
635
|
+
return del;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async #logLatency(now, data){
|
|
639
|
+
if(data.client_id == this.#getClientId()){
|
|
640
|
+
this.#log("Skipping latency log for own message");
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if(this.#latency.length >= 100){
|
|
645
|
+
this.#log("Latency array is full, skipping log");
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
650
|
+
|
|
651
|
+
this.#log(`Timezone: ${timeZone}`);
|
|
652
|
+
|
|
653
|
+
const latency = now - data.start
|
|
654
|
+
this.#log(`Latency => ${latency}`)
|
|
655
|
+
|
|
656
|
+
this.#latency.push({
|
|
657
|
+
latency: latency,
|
|
658
|
+
timestamp: now
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
if(this.#latencyPush == null){
|
|
662
|
+
this.#latencyPush = setTimeout(async () => {
|
|
663
|
+
this.#log("setTimeout called");
|
|
664
|
+
|
|
665
|
+
if(this.#latency.length > 0){
|
|
666
|
+
this.#log("Push from setTimeout")
|
|
667
|
+
await this.#pushLatencyData({
|
|
668
|
+
timezone: timeZone,
|
|
669
|
+
history: this.#latency,
|
|
670
|
+
});
|
|
671
|
+
}else{
|
|
672
|
+
this.#log("No latency data to push");
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
}, 30000);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if(this.#latency.length == 100 && !this.#isSendingLatency){
|
|
679
|
+
this.#log("Push from Length Check: " + this.#latency.length);
|
|
680
|
+
await this.#pushLatencyData({
|
|
681
|
+
timezone: timeZone,
|
|
682
|
+
history: this.#latency,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Utility functions
|
|
688
|
+
#getClientId(){
|
|
689
|
+
return this.#natsClient?.info?.client_id
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async #pushLatencyData(data){
|
|
693
|
+
this.#isSendingLatency = true;
|
|
694
|
+
|
|
695
|
+
try{
|
|
696
|
+
var res = await this.#natsClient.request("accounts.user.log_latency",
|
|
697
|
+
JSONCodec().encode({
|
|
698
|
+
api_key: this.api_key,
|
|
699
|
+
payload: data
|
|
700
|
+
}),
|
|
701
|
+
{
|
|
702
|
+
timeout: 5000
|
|
703
|
+
}
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
var data = res.json()
|
|
707
|
+
|
|
708
|
+
this.#log(data)
|
|
709
|
+
this.#resetLatencyTracker();
|
|
710
|
+
}catch(err){
|
|
711
|
+
this.#log("Error getting pushing latency data")
|
|
712
|
+
this.#log(err);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
this.#isSendingLatency = false;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
#resetLatencyTracker(){
|
|
719
|
+
this.#latency = [];
|
|
720
|
+
|
|
721
|
+
if(this.#latencyPush != null){
|
|
722
|
+
clearTimeout(this.#latencyPush);
|
|
723
|
+
this.#latencyPush = null;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Checks if a topic can be used to send messages to.
|
|
729
|
+
* @param {string} topic - Name of event
|
|
730
|
+
* @returns {boolean} - If topic is valid or not
|
|
731
|
+
*/
|
|
732
|
+
isTopicValid(topic){
|
|
733
|
+
if(topic !== null && topic !== undefined && (typeof topic) == "string"){
|
|
734
|
+
var arrayCheck = ![CONNECTED, DISCONNECTED, RECONNECT, this.#RECONNECTED,
|
|
735
|
+
this.#RECONNECTING, this.#RECONN_FAIL, MESSAGE_RESEND, SERVER_DISCONNECT].includes(topic);
|
|
736
|
+
|
|
737
|
+
var spaceStarCheck = !topic.includes(" ") && !topic.includes("*") && !topic.includes(".");
|
|
738
|
+
|
|
739
|
+
return arrayCheck && spaceStarCheck;
|
|
740
|
+
}else{
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
#isMessageValid(message){
|
|
746
|
+
if(message == null || message == undefined){
|
|
747
|
+
throw new Error("$message cannot be null / undefined")
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if(typeof message == "string"){
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if(typeof message == "number"){
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if(this.#isJSON(message)){
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
#isJSON(data){
|
|
766
|
+
try{
|
|
767
|
+
JSON.stringify(data?.toString())
|
|
768
|
+
return true;
|
|
769
|
+
}catch(err){
|
|
770
|
+
return false
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
#getStreamName(){
|
|
775
|
+
if(this.namespace != null){
|
|
776
|
+
return this.namespace + "_stream"
|
|
777
|
+
}else{
|
|
778
|
+
this.close();
|
|
779
|
+
throw new Error("$namespace is null. Cannot initialize program with null $namespace")
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
#getStreamTopic(topic){
|
|
784
|
+
if(this.topicHash != null){
|
|
785
|
+
return this.topicHash + "." + topic;
|
|
786
|
+
}else{
|
|
787
|
+
this.close();
|
|
788
|
+
throw new Error("$topicHash is null. Cannot initialize program with null $topicHash")
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
sleep(ms) {
|
|
793
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
#log(msg){
|
|
797
|
+
if(this.opts?.debug){
|
|
798
|
+
console.log(msg);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
#getPublishRetry(){
|
|
803
|
+
this.#log(this.opts)
|
|
804
|
+
if(this.opts !== null && this.opts !== undefined){
|
|
805
|
+
if(this.opts.max_retries !== null && this.opts.max_retries !== undefined){
|
|
806
|
+
if (this.opts.max_retries <= 0){
|
|
807
|
+
return this.#maxPublishRetries;
|
|
808
|
+
}else{
|
|
809
|
+
return this.opts.max_retries;
|
|
810
|
+
}
|
|
811
|
+
}else{
|
|
812
|
+
return this.#maxPublishRetries;
|
|
813
|
+
}
|
|
814
|
+
}else{
|
|
815
|
+
return this.#maxPublishRetries;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
*
|
|
821
|
+
* @param {function} func - Function to execute under retry
|
|
822
|
+
* @param {int} count - Number of times to retry
|
|
823
|
+
* @param {int} delay - Delay between each retry
|
|
824
|
+
* @param {...any} args - Args to pass to func
|
|
825
|
+
* @returns {any} - Output of the func method
|
|
826
|
+
*/
|
|
827
|
+
async #retryTillSuccess(func, count, delay, ...args){
|
|
828
|
+
func = func.bind(this);
|
|
829
|
+
|
|
830
|
+
var output = null;
|
|
831
|
+
var success = false;
|
|
832
|
+
var methodDataOutput = null;
|
|
833
|
+
|
|
834
|
+
for(let i = 1; i <= count; i++){
|
|
835
|
+
this.#log(`Attempt ${i} at executing ${func.name}()`)
|
|
836
|
+
|
|
837
|
+
await this.sleep(delay)
|
|
838
|
+
|
|
839
|
+
output = await func(...args);
|
|
840
|
+
success = output.success;
|
|
841
|
+
|
|
842
|
+
methodDataOutput = output.output;
|
|
843
|
+
|
|
844
|
+
if (success){
|
|
845
|
+
this.#log(`Successfully called ${func.name}`)
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if(!success){
|
|
851
|
+
this.#log(`${func.name} executed ${count} times BUT not a success`);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return methodDataOutput;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
#getUserCreds(jwt, secret){
|
|
858
|
+
return `
|
|
859
|
+
-----BEGIN NATS USER JWT-----
|
|
860
|
+
${jwt}
|
|
861
|
+
------END NATS USER JWT------
|
|
862
|
+
|
|
863
|
+
************************* IMPORTANT *************************
|
|
864
|
+
NKEY Seed printed below can be used to sign and prove identity.
|
|
865
|
+
NKEYs are sensitive and should be treated as secrets.
|
|
866
|
+
|
|
867
|
+
-----BEGIN USER NKEY SEED-----
|
|
868
|
+
${secret}
|
|
869
|
+
------END USER NKEY SEED------
|
|
870
|
+
|
|
871
|
+
*************************************************************`
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Exposure for tests
|
|
875
|
+
testRetryTillSuccess(){
|
|
876
|
+
if(process.env.NODE_ENV == "test"){
|
|
877
|
+
return this.#retryTillSuccess.bind(this);
|
|
878
|
+
}else{
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
testGetPublishRetry(){
|
|
884
|
+
if(process.env.NODE_ENV == "test"){
|
|
885
|
+
return this.#getPublishRetry.bind(this);
|
|
886
|
+
}else{
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
testGetStreamName(){
|
|
892
|
+
if(process.env.NODE_ENV == "test"){
|
|
893
|
+
return this.#getStreamName.bind(this);
|
|
894
|
+
}else{
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
testGetStreamTopic(){
|
|
900
|
+
if(process.env.NODE_ENV == "test"){
|
|
901
|
+
return this.#getStreamTopic.bind(this);
|
|
902
|
+
}else{
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
testGetTopicMap(){
|
|
908
|
+
if(process.env.NODE_ENV == "test"){
|
|
909
|
+
return this.#topicMap
|
|
910
|
+
}else{
|
|
911
|
+
return null;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
testGetEventMap(){
|
|
916
|
+
if(process.env.NODE_ENV == "test"){
|
|
917
|
+
return this.#event_func
|
|
918
|
+
}else{
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
testGetConsumerMap(){
|
|
924
|
+
if(process.env.NODE_ENV == "test"){
|
|
925
|
+
return this.#consumerMap
|
|
926
|
+
}else{
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
export const CONNECTED = "CONNECTED";
|
|
933
|
+
export const RECONNECT = "RECONNECT";
|
|
934
|
+
export const MESSAGE_RESEND = "MESSAGE_RESEND";
|
|
935
|
+
export const DISCONNECTED = "DISCONNECTED";
|
|
936
|
+
export const SERVER_DISCONNECT = "SERVER_DISCONNECT";
|