relayx-js 1.0.19 → 1.1.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.
@@ -0,0 +1,653 @@
1
+ import { connect, JSONCodec, Events, DebugEvents, AckPolicy, ReplayPolicy, credsAuthenticator } from "nats";
2
+ import { DeliverPolicy, jetstream, jetstreamManager } from "@nats-io/jetstream";
3
+ import { encode, decode } from "@msgpack/msgpack";
4
+ import { v4 as uuidv4 } from 'uuid';
5
+ import { ErrorLogging } from "./utils.js";
6
+ import Message from "./models/message.js";
7
+
8
+ export class Queue {
9
+
10
+ #queueID = null;
11
+ #api_key = null;
12
+
13
+ #baseUrl = "";
14
+
15
+ #natsClient = null;
16
+ #codec = JSONCodec();
17
+ #jetstream = null;
18
+ #jetStreamManager = null;
19
+ #consumerMap = {};
20
+
21
+ #event_func = {};
22
+ #topicMap = [];
23
+
24
+ #errorLogging = null;
25
+ #debug = false;
26
+
27
+ // Status Codes
28
+ #RECONNECTING = "RECONNECTING";
29
+ #RECONNECTED = "RECONNECTED";
30
+ #RECONN_FAIL = "RECONN_FAIL";
31
+
32
+ CONNECTED = "CONNECTED";
33
+ RECONNECT = "RECONNECT";
34
+ MESSAGE_RESEND = "MESSAGE_RESEND";
35
+ DISCONNECTED = "DISCONNECTED";
36
+ SERVER_DISCONNECT = "SERVER_DISCONNECT";
37
+
38
+ #reservedSystemTopics = [this.CONNECTED, this.DISCONNECTED, this.RECONNECT, this.#RECONNECTED, this.#RECONNECTING, this.#RECONN_FAIL, this.MESSAGE_RESEND, this.SERVER_DISCONNECT];
39
+
40
+ setRemoteUserAttempts = 0;
41
+ setRemoteUserRetries = 5;
42
+
43
+ // Retry attempts end
44
+ reconnected = false;
45
+ disconnected = true;
46
+ reconnecting = false;
47
+ connected = true;
48
+
49
+ // Offline messages
50
+ #offlineMessageBuffer = [];
51
+
52
+ #connectCalled = false;
53
+
54
+ constructor(config){
55
+ this.#jetstream = config.jetstream;
56
+ this.#natsClient = config.nats_client;
57
+
58
+ this.#api_key = config.api_key;
59
+
60
+ this.#debug = config.debug;
61
+
62
+ this.#errorLogging = new ErrorLogging();
63
+ }
64
+
65
+ async init(queueID){
66
+ this.#queueID = queueID;
67
+
68
+ this.#jetStreamManager = await this.#jetstream.jetstreamManager()
69
+
70
+ var result = await this.#getQueueNamespace();
71
+
72
+ this.#initConnectionListener();
73
+
74
+ return result
75
+ }
76
+
77
+ /**
78
+ * Get namespace to start subscribing and publishing in the queue
79
+ */
80
+ async #getQueueNamespace(){
81
+ this.#log("Getting queue namespace data...")
82
+ var data = null;
83
+
84
+ try{
85
+ var res = await this.#natsClient.request("accounts.user.get_queue_namespace",
86
+ JSONCodec().encode({
87
+ api_key: this.#api_key,
88
+ queue_id: this.#queueID
89
+ }),
90
+ {
91
+ timeout: 5000
92
+ }
93
+ )
94
+
95
+ data = res.json()
96
+
97
+ this.#log(data)
98
+ }catch(err){
99
+ console.log("-------------------------")
100
+ console.log("Error fetching queue namespace!")
101
+ console.log(err);
102
+ console.log("-------------------------")
103
+
104
+ return false;
105
+ }
106
+
107
+ if(data.status == "NAMESPACE_RETRIEVE_SUCCESS"){
108
+ this.namespace = data.data.namespace
109
+ this.topicHash = data.data.hash
110
+
111
+ return true;
112
+ }else{
113
+ this.namespace = null;
114
+ this.topicHash = null;
115
+
116
+ var code = data.code;
117
+
118
+ if(code == "QUEUE_NOT_FOUND"){
119
+ console.log("-------------------------------")
120
+ console.log(`Code: ${code}`)
121
+ console.log(`Description: The queue does not exist OR has been disabled`)
122
+ console.log(`Queue ID: ${this.#queueID}`)
123
+ console.log(`Docs Link To Resolve Problem: <>`)
124
+ console.log("-------------------------------")
125
+ }
126
+
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Connection listener to handle queue
133
+ */
134
+ async #initConnectionListener(){
135
+ (async () => {
136
+ for await (const s of this.#natsClient.status()) {
137
+ switch (s.type) {
138
+ case Events.Disconnect:
139
+ this.#log(`client disconnected - ${s.data}`);
140
+
141
+ this.connected = false;
142
+ break;
143
+ case Events.Reconnect:
144
+ this.#log(`client reconnected -`);
145
+ this.#log(s.data)
146
+
147
+ this.reconnecting = false;
148
+ this.connected = true;
149
+
150
+ // Resend any messages sent while client was offline
151
+ this.#publishMessagesOnReconnect();
152
+ break;
153
+ case DebugEvents.Reconnecting:
154
+ this.#log("client is attempting to reconnect");
155
+
156
+ this.reconnecting = true;
157
+ this.connected = false;
158
+ break;
159
+ case DebugEvents.StaleConnection:
160
+ this.#log("client has a stale connection");
161
+ break;
162
+ }
163
+ }
164
+ })().then();
165
+ }
166
+
167
+ /**
168
+ * A method to send a message to a queue topic.
169
+ * Retry methods included. Stores messages in an array if offline.
170
+ * @param {string} topic - Name of the event
171
+ * @param {object} data - Data to send
172
+ * @returns
173
+ */
174
+ async publish(topic, data){
175
+ if(topic == null || topic == undefined){
176
+ throw new Error("$topic is null or undefined");
177
+ }
178
+
179
+ if(topic == ""){
180
+ throw new Error("$topic cannot be an empty string")
181
+ }
182
+
183
+ if(typeof topic !== "string"){
184
+ throw new Error(`Expected $topic type -> string. Instead receieved -> ${typeof topic}`);
185
+ }
186
+
187
+ if(!this.isTopicValid(topic)){
188
+ throw new Error("Invalid topic, use isTopicValid($topic) to validate topic")
189
+ }
190
+
191
+ if(!this.isMessageValid(data)){
192
+ throw new Error("$message must be JSON, string or number")
193
+ }
194
+
195
+ var start = Date.now()
196
+ var messageId = crypto.randomUUID();
197
+
198
+ var message = {
199
+ "id": messageId,
200
+ "room": topic,
201
+ "message": data,
202
+ "start": Date.now()
203
+ }
204
+
205
+ if(this.connected){
206
+ this.#log("Encoding message via msg pack...")
207
+ var encodedMessage = encode(message);
208
+
209
+ this.#log(`Publishing to topic => ${this.#getStreamTopic(topic)}`)
210
+
211
+ var ack = null;
212
+
213
+ try{
214
+ ack = await this.#jetstream.publish(this.#getStreamTopic(topic), encodedMessage);
215
+ this.#log(`Publish Ack =>`)
216
+ this.#log(ack)
217
+
218
+ var latency = Date.now() - start;
219
+ this.#log(`Latency => ${latency} ms`);
220
+ }catch(err){
221
+ this.#errorLogging.logError({
222
+ err: err,
223
+ topic: topic,
224
+ op: "publish"
225
+ })
226
+ }
227
+
228
+ return ack !== null && ack !== undefined;
229
+ }else{
230
+ this.#offlineMessageBuffer.push({
231
+ topic: topic,
232
+ message: data
233
+ });
234
+
235
+ return false;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Subscribes to a topic
241
+ * @param {string} topic - Name of the event
242
+ * @param {function} func - Callback function to call on user thread
243
+ * @returns {boolean} - To check if topic subscription was successful
244
+ */
245
+ async consume(data, func){
246
+ var topic = data.topic;
247
+
248
+ if(!this.isTopicValid(topic) && this.#reservedSystemTopics.includes(topic)){
249
+ throw new Error(`Invalid Topic!`);
250
+ }
251
+
252
+ if ((typeof func !== "function")){
253
+ throw new Error(`Expected $listener type -> function. Instead receieved -> ${typeof func}`);
254
+ }
255
+
256
+ if(typeof topic !== "string"){
257
+ throw new Error(`Expected $topic type -> string. Instead receieved -> ${typeof topic}`);
258
+ }
259
+
260
+ if(topic in this.#event_func || this.#topicMap.includes(topic)){
261
+ return false
262
+ }
263
+
264
+ this.#event_func[topic] = func;
265
+
266
+ if (!this.#reservedSystemTopics.includes(topic)){
267
+ if(!this.isTopicValid(topic)){
268
+ throw new Error("Invalid topic, use isTopicValid($topic) to validate topic")
269
+ }
270
+
271
+ this.#topicMap.push(topic);
272
+
273
+ if(this.connected){
274
+ // Connected we need to create a topic in a stream
275
+ try{
276
+ await this.#startConsumer(data);
277
+ }catch(err){
278
+ this.#errorLogging.logError({
279
+ err: err,
280
+ topic: topic,
281
+ op: "subscribe"
282
+ })
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Deletes reference to user defined event callback.
290
+ * This will stop listening to a topic
291
+ * @param {string} topic
292
+ * @returns {boolean} - To check if topic unsubscribe was successful
293
+ */
294
+ async detachConsumer(topic){
295
+ if(topic == null || topic == undefined){
296
+ throw new Error("$topic is null / undefined")
297
+ }
298
+
299
+ if(typeof topic !== "string"){
300
+ throw new Error(`Expected $topic type -> string. Instead receieved -> ${typeof topic}`);
301
+ }
302
+
303
+ this.#topicMap = this.#topicMap.filter(item => item !== topic);
304
+
305
+ delete this.#event_func[topic];
306
+
307
+ delete this.#consumerMap[topic];
308
+
309
+ this.#log(`Consumer closed => ${topic}`)
310
+ }
311
+
312
+ /**
313
+ * Start consumers for topics initialized by user
314
+ */
315
+ async #subscribeToTopics(){
316
+ this.#topicMap.forEach(async (topic) => {
317
+ // Subscribe to stream
318
+ await this.#startConsumer(topic);
319
+ });
320
+ }
321
+
322
+ /**
323
+ * Method resends messages when the client successfully connects to the
324
+ * server again
325
+ * @returns - Array of success and failure messages
326
+ */
327
+ async #publishMessagesOnReconnect(){
328
+ var messageSentStatus = [];
329
+
330
+ for(let i = 0; i < this.#offlineMessageBuffer.length; i++){
331
+ let data = this.#offlineMessageBuffer[i];
332
+
333
+ const topic = data.topic;
334
+ const message = data.message;
335
+
336
+ const output = await this.publish(topic, message);
337
+
338
+ messageSentStatus.push({
339
+ topic: topic,
340
+ message: message,
341
+ resent: output
342
+ });
343
+ }
344
+
345
+ // Clearing out offline messages
346
+ this.#offlineMessageBuffer.length = 0;
347
+
348
+ // Send to client
349
+ if(this.MESSAGE_RESEND in this.#event_func && messageSentStatus.length > 0){
350
+ this.#event_func[this.MESSAGE_RESEND](messageSentStatus);
351
+ }
352
+ }
353
+
354
+ // Room functions
355
+ /**
356
+ * Starts consumer for particular topic if stream exists
357
+ * @param {string} topic
358
+ */
359
+ async #startConsumer(config){
360
+ this.#validateConsumerConfig(config);
361
+
362
+ var name = config.name;
363
+ var topic = config.topic;
364
+
365
+ var opts = {
366
+ name: name,
367
+ durable_name: name,
368
+ delivery_group: config.group,
369
+ delivery_policy: DeliverPolicy.New,
370
+ replay_policy: ReplayPolicy.Instant,
371
+ filter_subject: this.#getStreamTopic(topic),
372
+ ack_policy: AckPolicy.Explicit,
373
+ }
374
+
375
+ if(this.#checkVarOk(config.ack_wait) && !(config.ack_wait < 0) && (typeof config.ack_wait === "number")){
376
+ opts.ack_wait = config.ack_wait * 1_000_000_000 // Seconds to nano seconds
377
+ }
378
+
379
+ if(this.#checkVarOk(config.backoff) && Array.isArray(config.backoff)){
380
+ var backoffNanos = [];
381
+
382
+ for(let bo of config.backoff){
383
+ backoffNanos.push(bo * 1_000_000_000) // Seconds to nano seconds
384
+ }
385
+
386
+ opts.backoff = backoffNanos
387
+ }
388
+
389
+ if(this.#checkVarOk(config.max_deliver) && !(config.max_deliver < 0) && (typeof config.max_deliver === "number")){
390
+ opts.max_deliver = config.max_deliver
391
+ }else{
392
+ opts.max_deliver = -1
393
+ }
394
+
395
+ if(this.#checkVarOk(config.max_ack_pending) && !(config.max_ack_pending < 0) && (typeof config.max_ack_pending === "number")){
396
+ opts.max_ack_pending = config.max_ack_pending
397
+ }
398
+
399
+ var consumer = null;
400
+
401
+ try{
402
+ consumer = await this.#jetStreamManager.consumers.info(this.#getQueueName(), name);
403
+ }catch(err){
404
+ consumer = null;
405
+ }
406
+
407
+ if(consumer == null){
408
+ this.#log("Consumer not found, creating...")
409
+
410
+ await this.#jetStreamManager.consumers.add(this.#getQueueName(), opts);
411
+
412
+ this.#log(`Consumer created: ${name}`)
413
+ }else{
414
+ this.#log("Consumer found, updating...")
415
+
416
+ await this.#jetStreamManager.consumers.update(this.#getQueueName(), name, opts);
417
+
418
+ this.#log(`Consumer updated: ${name}`)
419
+ }
420
+
421
+ consumer = await this.#jetstream.consumers.get(this.#getQueueName(), name)
422
+
423
+ this.#consumerMap[topic] = consumer;
424
+
425
+ while(true){
426
+ var msg = await consumer.next({
427
+ expires: 1000
428
+ });
429
+
430
+ if(!this.#checkVarOk(this.#consumerMap[topic])){
431
+ // consumerMap has no callback function because
432
+ // we called detachConsumer(). We did that because
433
+ // we did not want to consumer messages anymore
434
+
435
+ break;
436
+ }
437
+
438
+ if(msg == null){
439
+ continue
440
+ }
441
+
442
+ try{
443
+ const now = Date.now();
444
+ msg.working()
445
+ this.#log("Decoding msgpack message...")
446
+ var data = decode(msg.data);
447
+
448
+ var msgTopic = this.#stripStreamHash(msg.subject);
449
+
450
+ this.#log(data);
451
+
452
+ // Push topic message to main thread
453
+ var topicMatch = this.#topicPatternMatcher(topic, msgTopic)
454
+
455
+ if(topicMatch){
456
+ var fMsg = {
457
+ id: data.id,
458
+ topic: msgTopic,
459
+ message: data.message,
460
+ msg: msg
461
+ }
462
+
463
+ var msgObj = new Message(fMsg)
464
+
465
+ this.#event_func[topic](msgObj);
466
+ }
467
+ }catch(err){
468
+ this.#log("Consumer err " + err);
469
+ msg.nak(5000);
470
+ }
471
+ }
472
+
473
+ this.#log(`Consumer done => ${topic}`)
474
+ }
475
+
476
+ async deleteConsumer(name){
477
+ var del = false;
478
+
479
+ try{
480
+ del = await this.#jetStreamManager.consumers.delete(this.#getQueueName(), name)
481
+ }catch(err){
482
+ this.#log("Failed to delete consumer!")
483
+ this.#log(err)
484
+ }
485
+
486
+ return del;
487
+ }
488
+
489
+ // Utility functions
490
+ #getClientId(){
491
+ return this.#natsClient?.info?.client_id
492
+ }
493
+
494
+ /**
495
+ * Checks if a topic can be used to send messages to.
496
+ * @param {string} topic - Name of event
497
+ * @returns {boolean} - If topic is valid or not
498
+ */
499
+ isTopicValid(topic){
500
+ if(topic !== null && topic !== undefined && (typeof topic) == "string"){
501
+ var arrayCheck = !this.#reservedSystemTopics.includes(topic);
502
+
503
+ const TOPIC_REGEX = /^(?!.*\$)(?:[A-Za-z0-9_*~-]+(?:\.[A-Za-z0-9_*~-]+)*(?:\.>)?|>)$/u;
504
+
505
+ var spaceStarCheck = !topic.includes(" ") && TOPIC_REGEX.test(topic);
506
+
507
+ return arrayCheck && spaceStarCheck;
508
+ }else{
509
+ return false;
510
+ }
511
+ }
512
+
513
+ isMessageValid(message){
514
+ if(message == null || message == undefined){
515
+ throw new Error("$message cannot be null / undefined")
516
+ }
517
+
518
+ if(typeof message == "string"){
519
+ return true;
520
+ }
521
+
522
+ if(typeof message == "number"){
523
+ return true;
524
+ }
525
+
526
+ if(this.#isJSON(message)){
527
+ return true;
528
+ }
529
+
530
+ return false;
531
+ }
532
+
533
+ #validateConsumerConfig(config){
534
+ if(config === null || config === undefined){
535
+ throw new Error("$config (subscribe config) cannot be null / undefined")
536
+ }
537
+
538
+ if(config.name === null || config.name === undefined || config.name == ""){
539
+ throw new Error("$config.name (subscribe config) cannot be null / undefined / Empty")
540
+ }
541
+
542
+ if(config.topic === null || config.topic === undefined || config.topic == ""){
543
+ throw new Error("$config.topic (subscribe config) cannot be null / undefined")
544
+ }
545
+
546
+ if(config.group === null || config.group === undefined || config.group == ""){
547
+ throw new Error("$config.group (subscribe config) cannot be null / undefined")
548
+ }
549
+ }
550
+
551
+ #isJSON(data){
552
+ try{
553
+ JSON.stringify(data?.toString())
554
+ return true;
555
+ }catch(err){
556
+ return false
557
+ }
558
+ }
559
+
560
+ #checkVarOk(variable){
561
+ return variable !== null && variable !== undefined
562
+ }
563
+
564
+ #getQueueName(){
565
+ if(this.namespace != null){
566
+ return `Q_${this.namespace}`
567
+ }else{
568
+ this.close();
569
+ throw new Error("$namespace is null. Cannot initialize program with null $namespace")
570
+ }
571
+ }
572
+
573
+ #getStreamTopic(topic){
574
+ if(this.topicHash != null){
575
+ return `${this.topicHash}.${topic}`;
576
+ }else{
577
+ throw new Error("$topicHash is null. Cannot initialize program with null $topicHash")
578
+ }
579
+ }
580
+
581
+ #stripStreamHash(topic){
582
+ return topic.replace(`${this.topicHash}.`, "")
583
+ }
584
+
585
+ #topicPatternMatcher(patternA, patternB) {
586
+ const a = patternA.split(".");
587
+ const b = patternB.split(".");
588
+
589
+ let i = 0, j = 0; // cursors in a & b
590
+ let starAi = -1, starAj = -1; // last '>' position in A and the token count it has consumed
591
+ let starBi = -1, starBj = -1; // same for pattern B
592
+
593
+ while (i < a.length || j < b.length) {
594
+ const tokA = a[i];
595
+ const tokB = b[j];
596
+
597
+ /*────────────────── multi‑token wildcard ">" — must be **final** ───────────────*/
598
+ if (tokA === ">") {
599
+ if (i !== a.length - 1) return false; // '>' not in last position → invalid
600
+ if (j >= b.length) return false; // must consume at least one token
601
+ starAi = i++; // remember where '>' is
602
+ starAj = ++j; // gobble one token from B
603
+ continue;
604
+ }
605
+ if (tokB === ">") {
606
+ if (j !== b.length - 1) return false; // same rule for patternB
607
+ if (i >= a.length) return false;
608
+ starBi = j++;
609
+ starBj = ++i;
610
+ continue;
611
+ }
612
+
613
+ /*──────────── literal match or single‑token wildcard on either side ────────────*/
614
+ const singleWildcard =
615
+ (tokA === "*" && j < b.length) ||
616
+ (tokB === "*" && i < a.length);
617
+
618
+ if (
619
+ (tokA !== undefined && tokA === tokB) ||
620
+ singleWildcard
621
+ ) {
622
+ i++; j++;
623
+ continue;
624
+ }
625
+
626
+ /*───────────────────────────── back‑track using last '>' ───────────────────────*/
627
+ if (starAi !== -1) { // let patternA's '>' absorb one more token of B
628
+ j = ++starAj;
629
+ continue;
630
+ }
631
+ if (starBi !== -1) { // let patternB's '>' absorb one more token of A
632
+ i = ++starBj;
633
+ continue;
634
+ }
635
+
636
+ /*────────────────────────────────── dead‑end ───────────────────────────────────*/
637
+ return false;
638
+ }
639
+
640
+ return true;
641
+ }
642
+
643
+ sleep(ms) {
644
+ return new Promise(resolve => setTimeout(resolve, ms));
645
+ }
646
+
647
+ #log(msg){
648
+ if(this.#debug){
649
+ console.log(msg);
650
+ }
651
+ }
652
+
653
+ }