relayx-webjs 1.0.4 → 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,660 @@
1
+ import { connect, JSONCodec, Events, DebugEvents, AckPolicy, ReplayPolicy, credsAuthenticator } from "nats.ws";
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
+ "client_id": this.#getClientId(),
200
+ "id": messageId,
201
+ "room": topic,
202
+ "message": data,
203
+ "start": Date.now()
204
+ }
205
+
206
+ if(this.connected){
207
+ this.#log("Encoding message via msg pack...")
208
+ var encodedMessage = encode(message);
209
+
210
+ this.#log(`Publishing to topic => ${this.#getStreamTopic(topic)}`)
211
+
212
+ var ack = null;
213
+
214
+ try{
215
+ ack = await this.#jetstream.publish(this.#getStreamTopic(topic), encodedMessage);
216
+ this.#log(`Publish Ack =>`)
217
+ this.#log(ack)
218
+
219
+ var latency = Date.now() - start;
220
+ this.#log(`Latency => ${latency} ms`);
221
+ }catch(err){
222
+ this.#errorLogging.logError({
223
+ err: err,
224
+ topic: topic,
225
+ op: "publish"
226
+ })
227
+ }
228
+
229
+ return ack !== null && ack !== undefined;
230
+ }else{
231
+ this.#offlineMessageBuffer.push({
232
+ topic: topic,
233
+ message: data
234
+ });
235
+
236
+ return false;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Subscribes to a topic
242
+ * @param {string} topic - Name of the event
243
+ * @param {function} func - Callback function to call on user thread
244
+ * @returns {boolean} - To check if topic subscription was successful
245
+ */
246
+ async consume(data, func){
247
+ var topic = data.topic;
248
+
249
+ if(!this.isTopicValid(topic) && this.#reservedSystemTopics.includes(topic)){
250
+ throw new Error(`Invalid Topic!`);
251
+ }
252
+
253
+ if ((typeof func !== "function")){
254
+ throw new Error(`Expected $listener type -> function. Instead receieved -> ${typeof func}`);
255
+ }
256
+
257
+ if(typeof topic !== "string"){
258
+ throw new Error(`Expected $topic type -> string. Instead receieved -> ${typeof topic}`);
259
+ }
260
+
261
+ if(topic in this.#event_func || this.#topicMap.includes(topic)){
262
+ return false
263
+ }
264
+
265
+ this.#event_func[topic] = func;
266
+
267
+ if (!this.#reservedSystemTopics.includes(topic)){
268
+ if(!this.isTopicValid(topic)){
269
+ throw new Error("Invalid topic, use isTopicValid($topic) to validate topic")
270
+ }
271
+
272
+ this.#topicMap.push(topic);
273
+
274
+ if(this.connected){
275
+ // Connected we need to create a topic in a stream
276
+ try{
277
+ await this.#startConsumer(data);
278
+ }catch(err){
279
+ this.#errorLogging.logError({
280
+ err: err,
281
+ topic: topic,
282
+ op: "subscribe"
283
+ })
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Deletes reference to user defined event callback.
291
+ * This will stop listening to a topic
292
+ * @param {string} topic
293
+ * @returns {boolean} - To check if topic unsubscribe was successful
294
+ */
295
+ async detachConsumer(topic){
296
+ if(topic == null || topic == undefined){
297
+ throw new Error("$topic is null / undefined")
298
+ }
299
+
300
+ if(typeof topic !== "string"){
301
+ throw new Error(`Expected $topic type -> string. Instead receieved -> ${typeof topic}`);
302
+ }
303
+
304
+ this.#topicMap = this.#topicMap.filter(item => item !== topic);
305
+
306
+ delete this.#event_func[topic];
307
+
308
+ delete this.#consumerMap[topic];
309
+
310
+ this.#log(`Consumer closed => ${topic}`)
311
+ }
312
+
313
+ /**
314
+ * Start consumers for topics initialized by user
315
+ */
316
+ async #subscribeToTopics(){
317
+ this.#topicMap.forEach(async (topic) => {
318
+ // Subscribe to stream
319
+ await this.#startConsumer(topic);
320
+ });
321
+ }
322
+
323
+ /**
324
+ * Method resends messages when the client successfully connects to the
325
+ * server again
326
+ * @returns - Array of success and failure messages
327
+ */
328
+ async #publishMessagesOnReconnect(){
329
+ var messageSentStatus = [];
330
+
331
+ for(let i = 0; i < this.#offlineMessageBuffer.length; i++){
332
+ let data = this.#offlineMessageBuffer[i];
333
+
334
+ const topic = data.topic;
335
+ const message = data.message;
336
+
337
+ const output = await this.publish(topic, message);
338
+
339
+ messageSentStatus.push({
340
+ topic: topic,
341
+ message: message,
342
+ resent: output
343
+ });
344
+ }
345
+
346
+ // Clearing out offline messages
347
+ this.#offlineMessageBuffer.length = 0;
348
+
349
+ // Send to client
350
+ if(this.MESSAGE_RESEND in this.#event_func && messageSentStatus.length > 0){
351
+ this.#event_func[this.MESSAGE_RESEND](messageSentStatus);
352
+ }
353
+ }
354
+
355
+ // Room functions
356
+ /**
357
+ * Starts consumer for particular topic if stream exists
358
+ * @param {string} topic
359
+ */
360
+ async #startConsumer(config){
361
+ this.#validateConsumerConfig(config);
362
+
363
+ var name = config.name;
364
+ var topic = config.topic;
365
+
366
+ var opts = {
367
+ name: name,
368
+ durable_name: name,
369
+ delivery_group: config.group,
370
+ delivery_policy: DeliverPolicy.New,
371
+ replay_policy: ReplayPolicy.Instant,
372
+ filter_subject: this.#getStreamTopic(topic),
373
+ ack_policy: AckPolicy.Explicit,
374
+ }
375
+
376
+ if(this.#checkVarOk(config.ack_wait) && !(config.ack_wait < 0) && (typeof config.ack_wait === "number")){
377
+ opts.ack_wait = config.ack_wait * 1_000_000_000 // Seconds to nano seconds
378
+ }
379
+
380
+ if(this.#checkVarOk(config.backoff) && Array.isArray(config.backoff)){
381
+ var backoffNanos = [];
382
+
383
+ for(let bo of config.backoff){
384
+ backoffNanos.push(bo * 1_000_000_000) // Seconds to nano seconds
385
+ }
386
+
387
+ opts.backoff = backoffNanos
388
+ }
389
+
390
+ if(this.#checkVarOk(config.max_deliver) && !(config.max_deliver < 0) && (typeof config.max_deliver === "number")){
391
+ opts.max_deliver = config.max_deliver
392
+ }else{
393
+ opts.max_deliver = -1
394
+ }
395
+
396
+ if(this.#checkVarOk(config.max_ack_pending) && !(config.max_ack_pending < 0) && (typeof config.max_ack_pending === "number")){
397
+ opts.max_ack_pending = config.max_ack_pending
398
+ }
399
+
400
+ var consumer = null;
401
+
402
+ try{
403
+ consumer = await this.#jetStreamManager.consumers.info(this.#getQueueName(), name);
404
+ }catch(err){
405
+ consumer = null;
406
+ }
407
+
408
+ if(consumer == null){
409
+ this.#log("Consumer not found, creating...")
410
+
411
+ await this.#jetStreamManager.consumers.add(this.#getQueueName(), opts);
412
+
413
+ this.#log(`Consumer created: ${name}`)
414
+ }else{
415
+ this.#log("Consumer found, updating...")
416
+
417
+ await this.#jetStreamManager.consumers.update(this.#getQueueName(), name, opts);
418
+
419
+ this.#log(`Consumer updated: ${name}`)
420
+ }
421
+
422
+ consumer = await this.#jetstream.consumers.get(this.#getQueueName(), name)
423
+
424
+ this.#consumerMap[topic] = consumer;
425
+
426
+ while(true){
427
+ var msg = await consumer.next({
428
+ expires: 1000
429
+ });
430
+
431
+ if(!this.#checkVarOk(this.#consumerMap[topic])){
432
+ // consumerMap has no callback function because
433
+ // we called detachConsumer(). We did that because
434
+ // we did not want to consumer messages anymore
435
+
436
+ break;
437
+ }
438
+
439
+ if(msg == null){
440
+ continue
441
+ }
442
+
443
+ try{
444
+ const now = Date.now();
445
+ msg.working()
446
+ this.#log("Decoding msgpack message...")
447
+ var data = decode(msg.data);
448
+
449
+ var msgTopic = this.#stripStreamHash(msg.subject);
450
+
451
+ this.#log(data);
452
+
453
+ // Push topic message to main thread
454
+ if (data.client_id != this.#getClientId()){
455
+ var topicMatch = this.#topicPatternMatcher(topic, msgTopic)
456
+
457
+ if(topicMatch){
458
+ var fMsg = {
459
+ id: data.id,
460
+ topic: msgTopic,
461
+ message: data.message,
462
+ msg: msg
463
+ }
464
+
465
+ var msgObj = new Message(fMsg)
466
+
467
+ this.#event_func[topic](msgObj);
468
+ }
469
+ }
470
+ }catch(err){
471
+ this.#log("Consumer err " + err);
472
+ msg.nak(5000);
473
+ }
474
+ }
475
+
476
+ this.#log(`Consumer done => ${topic}`)
477
+ }
478
+
479
+ async deleteConsumer(topic){
480
+ this.#log(topic)
481
+ const consumer = this.#consumerMap[topic]
482
+
483
+ var del = false;
484
+
485
+ if (consumer != null && consumer != undefined){
486
+ del = await consumer.delete();
487
+ }else{
488
+ del = false
489
+ }
490
+
491
+ delete this.#consumerMap[topic];
492
+
493
+ return del;
494
+ }
495
+
496
+ // Utility functions
497
+ #getClientId(){
498
+ return this.#natsClient?.info?.client_id
499
+ }
500
+
501
+ /**
502
+ * Checks if a topic can be used to send messages to.
503
+ * @param {string} topic - Name of event
504
+ * @returns {boolean} - If topic is valid or not
505
+ */
506
+ isTopicValid(topic){
507
+ if(topic !== null && topic !== undefined && (typeof topic) == "string"){
508
+ var arrayCheck = !this.#reservedSystemTopics.includes(topic);
509
+
510
+ const TOPIC_REGEX = /^(?!.*\$)(?:[A-Za-z0-9_*~-]+(?:\.[A-Za-z0-9_*~-]+)*(?:\.>)?|>)$/u;
511
+
512
+ var spaceStarCheck = !topic.includes(" ") && TOPIC_REGEX.test(topic);
513
+
514
+ return arrayCheck && spaceStarCheck;
515
+ }else{
516
+ return false;
517
+ }
518
+ }
519
+
520
+ isMessageValid(message){
521
+ if(message == null || message == undefined){
522
+ throw new Error("$message cannot be null / undefined")
523
+ }
524
+
525
+ if(typeof message == "string"){
526
+ return true;
527
+ }
528
+
529
+ if(typeof message == "number"){
530
+ return true;
531
+ }
532
+
533
+ if(this.#isJSON(message)){
534
+ return true;
535
+ }
536
+
537
+ return false;
538
+ }
539
+
540
+ #validateConsumerConfig(config){
541
+ if(config === null || config === undefined){
542
+ throw new Error("$config (subscribe config) cannot be null / undefined")
543
+ }
544
+
545
+ if(config.name === null || config.name === undefined || config.name == ""){
546
+ throw new Error("$config.name (subscribe config) cannot be null / undefined / Empty")
547
+ }
548
+
549
+ if(config.topic === null || config.topic === undefined || config.topic == ""){
550
+ throw new Error("$config.topic (subscribe config) cannot be null / undefined")
551
+ }
552
+
553
+ if(config.group === null || config.group === undefined || config.group == ""){
554
+ throw new Error("$config.group (subscribe config) cannot be null / undefined")
555
+ }
556
+ }
557
+
558
+ #isJSON(data){
559
+ try{
560
+ JSON.stringify(data?.toString())
561
+ return true;
562
+ }catch(err){
563
+ return false
564
+ }
565
+ }
566
+
567
+ #checkVarOk(variable){
568
+ return variable !== null && variable !== undefined
569
+ }
570
+
571
+ #getQueueName(){
572
+ if(this.namespace != null){
573
+ return `Q_${this.namespace}`
574
+ }else{
575
+ this.close();
576
+ throw new Error("$namespace is null. Cannot initialize program with null $namespace")
577
+ }
578
+ }
579
+
580
+ #getStreamTopic(topic){
581
+ if(this.topicHash != null){
582
+ return `${this.topicHash}.${topic}`;
583
+ }else{
584
+ throw new Error("$topicHash is null. Cannot initialize program with null $topicHash")
585
+ }
586
+ }
587
+
588
+ #stripStreamHash(topic){
589
+ return topic.replace(`${this.topicHash}.`, "")
590
+ }
591
+
592
+ #topicPatternMatcher(patternA, patternB) {
593
+ const a = patternA.split(".");
594
+ const b = patternB.split(".");
595
+
596
+ let i = 0, j = 0; // cursors in a & b
597
+ let starAi = -1, starAj = -1; // last '>' position in A and the token count it has consumed
598
+ let starBi = -1, starBj = -1; // same for pattern B
599
+
600
+ while (i < a.length || j < b.length) {
601
+ const tokA = a[i];
602
+ const tokB = b[j];
603
+
604
+ /*────────────────── multi‑token wildcard ">" — must be **final** ───────────────*/
605
+ if (tokA === ">") {
606
+ if (i !== a.length - 1) return false; // '>' not in last position → invalid
607
+ if (j >= b.length) return false; // must consume at least one token
608
+ starAi = i++; // remember where '>' is
609
+ starAj = ++j; // gobble one token from B
610
+ continue;
611
+ }
612
+ if (tokB === ">") {
613
+ if (j !== b.length - 1) return false; // same rule for patternB
614
+ if (i >= a.length) return false;
615
+ starBi = j++;
616
+ starBj = ++i;
617
+ continue;
618
+ }
619
+
620
+ /*──────────── literal match or single‑token wildcard on either side ────────────*/
621
+ const singleWildcard =
622
+ (tokA === "*" && j < b.length) ||
623
+ (tokB === "*" && i < a.length);
624
+
625
+ if (
626
+ (tokA !== undefined && tokA === tokB) ||
627
+ singleWildcard
628
+ ) {
629
+ i++; j++;
630
+ continue;
631
+ }
632
+
633
+ /*───────────────────────────── back‑track using last '>' ───────────────────────*/
634
+ if (starAi !== -1) { // let patternA's '>' absorb one more token of B
635
+ j = ++starAj;
636
+ continue;
637
+ }
638
+ if (starBi !== -1) { // let patternB's '>' absorb one more token of A
639
+ i = ++starBj;
640
+ continue;
641
+ }
642
+
643
+ /*────────────────────────────────── dead‑end ───────────────────────────────────*/
644
+ return false;
645
+ }
646
+
647
+ return true;
648
+ }
649
+
650
+ sleep(ms) {
651
+ return new Promise(resolve => setTimeout(resolve, ms));
652
+ }
653
+
654
+ #log(msg){
655
+ if(this.#debug){
656
+ console.log(msg);
657
+ }
658
+ }
659
+
660
+ }