relayx-js 1.0.16 → 1.0.17

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 CHANGED
@@ -1,3 +1,7 @@
1
+ V1.0.16
2
+ - Wildcard topics for pub / sub fixes
3
+ - Unit tests added for wildcard pattern matching
4
+
1
5
  V1.0.16
2
6
  - URL Fix
3
7
 
@@ -28,13 +28,26 @@ async function run(){
28
28
  console.log(`[IMPL] DISONNECT`)
29
29
  });
30
30
 
31
- await realtime.on("power-telemetry", (data) => {
32
- console.log("power-telemetry", data);
33
- });
34
-
35
- await realtime.on("hello.*", (data) => {
36
- console.log("hello.*", data);
37
- });
31
+ // await realtime.on("power-telemetry", (data) => {
32
+ // console.log("power-telemetry", data);
33
+ // });
34
+
35
+ // await realtime.on("hello.*", (data) => {
36
+ // console.log("hello.*", data);
37
+ // });
38
+
39
+ // await realtime.on("hello.>", async (data) => {
40
+ // await realtime.sleep(10000)
41
+ // console.log("hello.>", data);
42
+ // });
43
+
44
+ // await realtime.on("hello.hey.*", (data) => {
45
+ // console.log("hell.hey.*", data);
46
+ // });
47
+
48
+ // await realtime.on("hello.hey.>", (data) => {
49
+ // console.log("hello.hey.>", data);
50
+ // });
38
51
 
39
52
  realtime.on(MESSAGE_RESEND, (data) => {
40
53
  console.log(`[MSG RESEND] => ${data}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relayx-js",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "main": "realtime/realtime.js",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -12,6 +12,7 @@ export class Realtime {
12
12
  #codec = JSONCodec();
13
13
  #jetstream = null;
14
14
  #consumerMap = {};
15
+ #consumer = null;
15
16
 
16
17
  #event_func = {};
17
18
  #topicMap = [];
@@ -232,7 +233,6 @@ export class Realtime {
232
233
  this.#log(`client disconnected - ${s.data}`);
233
234
 
234
235
  this.connected = false;
235
- this.#consumerMap = {};
236
236
 
237
237
  if (DISCONNECTED in this.#event_func){
238
238
  if (this.#event_func[DISCONNECTED] !== null || this.#event_func[DISCONNECTED] !== undefined){
@@ -291,13 +291,17 @@ export class Realtime {
291
291
  /**
292
292
  * Closes connection
293
293
  */
294
- close(){
294
+ async close(){
295
295
  if(this.#natsClient !== null){
296
296
  this.reconnected = false;
297
297
  this.disconnected = true;
298
298
 
299
+ this.#consumer?.delete()
300
+
299
301
  this.#offlineMessageBuffer.length = 0;
300
302
 
303
+ await this.#deleteConsumer();
304
+
301
305
  this.#natsClient.close();
302
306
  }else{
303
307
  this.#log("Null / undefined socket, cannot close connection");
@@ -308,10 +312,9 @@ export class Realtime {
308
312
  * Start consumers for topics initialized by user
309
313
  */
310
314
  async #subscribeToTopics(){
311
- this.#topicMap.forEach(async (topic) => {
312
- // Subscribe to stream
313
- await this.#startConsumer(topic);
314
- });
315
+ if(this.#topicMap.length > 0){
316
+ await this.#startConsumer();
317
+ }
315
318
  }
316
319
 
317
320
  /**
@@ -332,8 +335,6 @@ export class Realtime {
332
335
  this.#topicMap = this.#topicMap.filter(item => item !== topic);
333
336
 
334
337
  delete this.#event_func[topic];
335
-
336
- return await this.#deleteConsumer(topic);
337
338
  }
338
339
 
339
340
  /**
@@ -476,6 +477,10 @@ export class Realtime {
476
477
  throw new Error(`Expected $topic type -> string. Instead receieved -> ${typeof topic}`);
477
478
  }
478
479
 
480
+ if(!this.isTopicValid(topic)){
481
+ throw new Error("Invalid topic, use isTopicValid($topic) to validate topic")
482
+ }
483
+
479
484
  if(start == undefined || start == null){
480
485
  throw new Error(`$start must be provided. $start is => ${start}`)
481
486
  }
@@ -576,40 +581,48 @@ export class Realtime {
576
581
  * Starts consumer for particular topic if stream exists
577
582
  * @param {string} topic
578
583
  */
579
- async #startConsumer(topic){
580
- this.#log(`Starting consumer for topic: ${topic}_${uuidv4()}`)
584
+ async #startConsumer(){
585
+ if(this.#consumer != null){
586
+ return;
587
+ }
581
588
 
582
589
  var opts = {
583
- name: `${topic}_${uuidv4()}`,
584
- filter_subjects: [this.#getStreamTopic(topic)],
590
+ name: `${uuidv4()}`,
591
+ filter_subjects: [this.#getStreamTopic(">")],
585
592
  replay_policy: ReplayPolicy.Instant,
586
593
  opt_start_time: new Date(),
587
594
  ack_policy: AckPolicy.Explicit,
588
595
  delivery_policy: DeliverPolicy.New
589
596
  }
590
597
 
591
- const consumer = await this.#jetstream.consumers.get(this.#getStreamName(), opts);
598
+ this.#consumer = await this.#jetstream.consumers.get(this.#getStreamName(), opts);
592
599
  this.#log(this.#topicMap)
593
600
 
594
- this.#consumerMap[topic] = consumer;
595
-
596
- await consumer.consume({
601
+ await this.#consumer.consume({
597
602
  callback: async (msg) => {
598
603
  try{
599
604
  const now = Date.now();
600
605
  this.#log("Decoding msgpack message...")
601
606
  var data = decode(msg.data);
602
607
 
603
- var room = data.room;
608
+ var room = this.#stripStreamHash(msg.subject);
604
609
 
605
610
  this.#log(data);
606
611
 
607
612
  // Push topic message to main thread
608
- if (room in this.#event_func && data.client_id != this.#getClientId()){
609
- this.#event_func[room]({
610
- "id": data.id,
611
- "data": data.message
612
- });
613
+ if (data.client_id != this.#getClientId()){
614
+ var topics = this.#getCallbackTopics(room);
615
+ this.#log(topics)
616
+
617
+ for(let i = 0; i < topics.length; i++){
618
+ var top = topics[i];
619
+
620
+ this.#event_func[top]({
621
+ "id": data.id,
622
+ "topic": room,
623
+ "data": data.message
624
+ });
625
+ }
613
626
  }
614
627
 
615
628
  msg.ack();
@@ -617,30 +630,22 @@ export class Realtime {
617
630
  await this.#logLatency(now, data);
618
631
  }catch(err){
619
632
  this.#log("Consumer err " + err);
620
- msg.nack(5000);
633
+ msg.nak(5000);
621
634
  }
622
635
  }
623
636
  });
624
637
  this.#log("Consumer is consuming");
625
638
  }
626
639
 
627
- /**
628
- * Deletes consumer
629
- * @param {string} topic
630
- */
631
- async #deleteConsumer(topic){
632
- const consumer = this.#consumerMap[topic]
633
-
640
+ async #deleteConsumer(){
634
641
  var del = false;
635
642
 
636
- if (consumer != null && consumer != undefined){
637
- del = await consumer.delete();
643
+ if (this.#consumer != null && this.#consumer != undefined){
644
+ del = await this.#consumer.delete();
638
645
  }else{
639
646
  del = false
640
647
  }
641
648
 
642
- delete this.#consumerMap[topic];
643
-
644
649
  return del;
645
650
  }
646
651
 
@@ -743,7 +748,7 @@ export class Realtime {
743
748
  var arrayCheck = ![CONNECTED, DISCONNECTED, RECONNECT, this.#RECONNECTED,
744
749
  this.#RECONNECTING, this.#RECONN_FAIL, MESSAGE_RESEND, SERVER_DISCONNECT].includes(topic);
745
750
 
746
- const TOPIC_REGEX = /^(?!\$)[A-Za-z0-9_,.*>\$-]+$/;
751
+ const TOPIC_REGEX = /^(?!.*\$)(?:[A-Za-z0-9_*~-]+(?:\.[A-Za-z0-9_*~-]+)*(?:\.>)?|>)$/u;
747
752
 
748
753
  var spaceStarCheck = !topic.includes(" ") && TOPIC_REGEX.test(topic);
749
754
 
@@ -800,6 +805,91 @@ export class Realtime {
800
805
  }
801
806
  }
802
807
 
808
+ #stripStreamHash(topic){
809
+ return topic.replace(`${this.topicHash}.`, "")
810
+ }
811
+
812
+ #getCallbackTopics(topic){
813
+ var validTopics = [];
814
+
815
+ var topicPatterns = Object.keys(this.#event_func);
816
+
817
+ for(let i = 0; i < topicPatterns.length; i++){
818
+ var pattern = topicPatterns[i];
819
+
820
+ if([CONNECTED, RECONNECT, MESSAGE_RESEND, DISCONNECTED, SERVER_DISCONNECT].includes(pattern)){
821
+ continue;
822
+ }
823
+
824
+ var match = this.#topicPatternMatcher(pattern, topic);
825
+
826
+ if(match){
827
+ validTopics.push(pattern)
828
+ }
829
+ }
830
+
831
+ return validTopics;
832
+
833
+ }
834
+
835
+ #topicPatternMatcher(patternA, patternB) {
836
+ const a = patternA.split(".");
837
+ const b = patternB.split(".");
838
+
839
+ let i = 0, j = 0; // cursors in a & b
840
+ let starAi = -1, starAj = -1; // last '>' position in A and the token count it has consumed
841
+ let starBi = -1, starBj = -1; // same for pattern B
842
+
843
+ while (i < a.length || j < b.length) {
844
+ const tokA = a[i];
845
+ const tokB = b[j];
846
+
847
+ /*──────────── literal match or single‑token wildcard on either side ────────────*/
848
+ const singleWildcard =
849
+ (tokA === "*" && j < b.length) ||
850
+ (tokB === "*" && i < a.length);
851
+
852
+ if (
853
+ (tokA !== undefined && tokA === tokB) ||
854
+ singleWildcard
855
+ ) {
856
+ i++; j++;
857
+ continue;
858
+ }
859
+
860
+ /*────────────────── multi‑token wildcard ">" — must be **final** ───────────────*/
861
+ if (tokA === ">") {
862
+ if (i !== a.length - 1) return false; // '>' not in last position → invalid
863
+ if (j >= b.length) return false; // must consume at least one token
864
+ starAi = i++; // remember where '>' is
865
+ starAj = ++j; // gobble one token from B
866
+ continue;
867
+ }
868
+ if (tokB === ">") {
869
+ if (j !== b.length - 1) return false; // same rule for patternB
870
+ if (i >= a.length) return false;
871
+ starBi = j++;
872
+ starBj = ++i;
873
+ continue;
874
+ }
875
+
876
+ /*───────────────────────────── back‑track using last '>' ───────────────────────*/
877
+ if (starAi !== -1) { // let patternA's '>' absorb one more token of B
878
+ j = ++starAj;
879
+ continue;
880
+ }
881
+ if (starBi !== -1) { // let patternB's '>' absorb one more token of A
882
+ i = ++starBj;
883
+ continue;
884
+ }
885
+
886
+ /*────────────────────────────────── dead‑end ───────────────────────────────────*/
887
+ return false;
888
+ }
889
+
890
+ return true;
891
+ }
892
+
803
893
  sleep(ms) {
804
894
  return new Promise(resolve => setTimeout(resolve, ms));
805
895
  }
@@ -930,6 +1020,14 @@ export class Realtime {
930
1020
  return null;
931
1021
  }
932
1022
  }
1023
+
1024
+ testPatternMatcher(){
1025
+ if(process.env.NODE_ENV == "test"){
1026
+ return this.#topicPatternMatcher.bind(this)
1027
+ }else{
1028
+ return null;
1029
+ }
1030
+ }
933
1031
  }
934
1032
 
935
1033
  export const CONNECTED = "CONNECTED";
package/tests/test.js CHANGED
@@ -476,36 +476,38 @@ test("Test isTopicValidMethod()", () => {
476
476
  });
477
477
 
478
478
  unreservedInvalidTopics = [
479
- '$internal', // starts with $
480
- 'hello world', // space
481
- 'topic/', // slash
482
- 'name?', // ?
483
- 'foo#bar', // #
484
- 'bar.baz!', // !
485
- ' space', // leading space
486
- 'tab\tchar', // tab
487
- 'line\nbreak', // newline
488
- 'comma ,', // space + comma
489
- '', // empty string
490
- 'bad|pipe', // |
491
- 'semi;colon', // ;
492
- 'colon:here', // :
493
- "quote's", // '
494
- '"doublequote"', // "
495
- 'brackets[]', // []
496
- 'brace{}', // {}
497
- 'paren()', // ()
498
- 'plus+sign', // +
499
- 'eq=val', // =
500
- 'gt>lt<', // < mixed with >
501
- 'percent%', // %
502
- 'caret^', // ^
503
- 'ampersand&', // &
504
- 'back\\slash', // backslash
505
- '中文字符', // non‑ASCII
506
- '👍emoji', // emoji
507
- 'foo\rbar', // carriage return
508
- 'end ' // trailing space
479
+ "$foo",
480
+ "foo$",
481
+ "foo.$.bar",
482
+ "foo..bar",
483
+ ".foo",
484
+ "foo.",
485
+ "foo.>.bar",
486
+ ">foo",
487
+ "foo>bar",
488
+ "foo.>bar",
489
+ "foo.bar.>.",
490
+ "foo bar",
491
+ "foo/bar",
492
+ "foo#bar",
493
+ "",
494
+ " ",
495
+ "..",
496
+ ".>",
497
+ "foo..",
498
+ ".",
499
+ ">.",
500
+ "foo,baz",
501
+ "αbeta",
502
+ "foo|bar",
503
+ "foo;bar",
504
+ "foo:bar",
505
+ "foo%bar",
506
+ "foo.*.>.bar",
507
+ "foo.*.>.",
508
+ "foo.*..bar",
509
+ "foo.>.bar",
510
+ "foo>"
509
511
  ];
510
512
 
511
513
  unreservedInvalidTopics.forEach(topic => {
@@ -514,40 +516,41 @@ test("Test isTopicValidMethod()", () => {
514
516
  });
515
517
 
516
518
  var unreservedValidTopics = [
517
- 'Orders',
518
- 'customer_123',
519
- 'foo-bar',
520
- 'a,b,c',
521
- '*',
522
- 'foo>*',
523
- 'hello$world',
524
- 'topic.123',
525
- 'ABC_def-ghi',
526
- 'data_stream_2025',
527
- 'NODE*',
528
- 'pubsub>events',
529
- 'log,metric,error',
530
- 'X123_Y456',
531
- 'multi.step.topic',
532
- 'batch-process',
533
- 'sensor1_data',
534
- 'finance$Q2',
535
- 'alpha,beta,gamma',
536
- 'Z9_Y8-X7',
537
- 'config>*',
538
- 'route-map',
539
- 'STATS_2025-07',
540
- 'msg_queue*',
541
- 'update>patch',
542
- 'pipeline_v2',
543
- 'FOO$BAR$BAZ',
544
- 'user.profile',
545
- 'id_001-xyz',
546
- 'event_queue>'
519
+ "foo",
520
+ "foo.bar",
521
+ "foo.bar.baz",
522
+ "*",
523
+ "foo.*",
524
+ "*.bar",
525
+ "foo.*.baz",
526
+ ">",
527
+ "foo.>",
528
+ "foo.bar.>",
529
+ "*.*.>",
530
+ "alpha_beta",
531
+ "alpha-beta",
532
+ "alpha~beta",
533
+ "abc123",
534
+ "123abc",
535
+ "~",
536
+ "alpha.*.>",
537
+ "alpha.*",
538
+ "alpha.*.*",
539
+ "-foo",
540
+ "foo_bar-baz~qux",
541
+ "A.B.C",
542
+ "sensor.temperature",
543
+ "metric.cpu.load",
544
+ "foo.*.*",
545
+ "foo.*.>",
546
+ "foo_bar.*",
547
+ "*.*",
548
+ "metrics.>"
547
549
  ];
548
550
 
549
551
  unreservedValidTopics.forEach(topic => {
550
552
  var valid = realTimeEnabled.isTopicValid(topic);
553
+ console.log(topic)
551
554
  assert.strictEqual(valid, true);
552
555
  });
553
556
  });
@@ -606,4 +609,77 @@ test("History test", async () => {
606
609
  },
607
610
  new Error("$start must be a Date object"),
608
611
  "Expected error was not thrown");
609
- })
612
+ })
613
+
614
+ test("Pattern matcher test", async () => {
615
+ var cases = [
616
+ ["foo", "foo", true], // 1
617
+ ["foo", "bar", false], // 2
618
+ ["foo.*", "foo.bar", true], // 3
619
+ ["foo.bar", "foo.*", true], // 4
620
+ ["*", "token", true], // 5
621
+ ["*", "*", true], // 6
622
+ ["foo.*", "foo.bar.baz", false], // 7
623
+ ["foo.>", "foo.bar.baz", true], // 8
624
+ ["foo.>", "foo", false], // 9 (zero‑token '>' now invalid)
625
+ ["foo.bar.baz", "foo.>", true], // 10
626
+ ["foo.bar.>", "foo.bar", false], // 11
627
+ ["foo", "foo.>", false], // 12
628
+ ["foo.*.>", "foo.bar.baz.qux", true], // 13
629
+ ["foo.*.baz", "foo.bar.>", true], // 14
630
+ ["alpha.*", "beta.gamma", false], // 15
631
+ ["alpha.beta", "alpha.*.*", false], // 16
632
+ ["foo.>.bar", "foo.any.bar", false], // 17 ('>' mid‑pattern)
633
+ [">", "foo.bar", true], // 18
634
+ [">", ">", true], // 19
635
+ ["*", ">", true], // 20
636
+ ["*.>", "foo.bar", true], // 21
637
+ ["*.*.*", "a.b.c", true], // 22
638
+ ["*.*.*", "a.b", false], // 23
639
+ ["a.b.c.d.e", "a.b.c.d.e", true], // 24
640
+ ["a.b.c.d.e", "a.b.c.d.f", false], // 25
641
+ ["a.b.*.d", "a.b.c.d", true], // 26
642
+ ["a.b.*.d", "a.b.c.e", false], // 27
643
+ ["a.b.>", "a.b", false], // 28
644
+ ["a.b", "a.b.c.d.>", false], // 29
645
+ ["a.b.>.c", "a.b.x.c", false], // 30
646
+ ["a.*.*", "a.b", false], // 31
647
+ ["a.*", "a.b.c", false], // 32
648
+ ["metrics.cpu.load", "metrics.*.load", true], // 33
649
+ ["metrics.cpu.load", "metrics.cpu.*", true], // 34
650
+ ["metrics.cpu.load", "metrics.>.load", false], // 35
651
+ ["metrics.>", "metrics", false], // 36
652
+ ["metrics.>", "othermetrics.cpu", false], // 37
653
+ ["*.*.>", "a.b", false], // 38
654
+ ["*.*.>", "a.b.c.d", true], // 39
655
+ ["a.b.c", "*.*.*", true], // 40
656
+ ["a.b.c", "*.*", false], // 41
657
+ ["alpha.*.>", "alpha", false], // 42
658
+ ["alpha.*.>", "alpha.beta", false], // 43
659
+ ["alpha.*.>", "alpha.beta.gamma", true], // 44
660
+ ["alpha.*.>", "beta.alpha.gamma", false], // 45
661
+ ["foo-bar_baz", "foo-bar_baz", true], // 46
662
+ ["foo-bar_*", "foo-bar_123", false], // 47 ( '*' here is literal )
663
+ ["foo-bar_*", "foo-bar_*", true], // 48
664
+ ["order-*", "order-123", false], // 49
665
+ ["hello.hey.*", "hello.hey.>", true] // 50
666
+ ];
667
+
668
+ var realtime = new Realtime({
669
+ api_key: process.env.AUTH_JWT,
670
+ secret: process.env.AUTH_SECRET
671
+ });
672
+
673
+ var patternMatcher = realtime.testPatternMatcher();
674
+
675
+ cases.forEach(testCase => {
676
+ var tokenA = testCase[0];
677
+ var tokenB = testCase[1];
678
+ var expectedResult = testCase[2];
679
+
680
+ console.log(`${tokenA} ⇆ ${tokenB} → ${expectedResult}`)
681
+
682
+ var result = patternMatcher(tokenA, tokenB)
683
+ assert.strictEqual(expectedResult, result)
684
+ });
685
+ })