relayx-webjs 1.0.1 → 1.0.2

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.2
2
+ - Wildcard topics for pub / sub fixes
3
+ - Unit tests added for wildcard pattern matching
4
+
1
5
  V1.0.1
2
6
  - Wildcard topics for pub / sub
3
7
  - Message replay on reconnection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relayx-webjs",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "A powerful library for integrating real-time communication into your webapps, powered by the Relay Network.",
5
5
  "main": "realtime/realtime.js",
6
6
  "type": "module",
@@ -11,6 +11,7 @@ export class Realtime {
11
11
  #codec = JSONCodec();
12
12
  #jetstream = null;
13
13
  #consumerMap = {};
14
+ #consumer = null;
14
15
 
15
16
  #event_func = {};
16
17
  #topicMap = [];
@@ -223,7 +224,6 @@ export class Realtime {
223
224
  this.#log(`client disconnected - ${s.data}`);
224
225
 
225
226
  this.connected = false;
226
- this.#consumerMap = {};
227
227
 
228
228
  if (DISCONNECTED in this.#event_func){
229
229
  if (this.#event_func[DISCONNECTED] !== null || this.#event_func[DISCONNECTED] !== undefined){
@@ -282,13 +282,15 @@ export class Realtime {
282
282
  /**
283
283
  * Closes connection
284
284
  */
285
- close(){
285
+ async close(){
286
286
  if(this.#natsClient !== null){
287
287
  this.reconnected = false;
288
288
  this.disconnected = true;
289
289
 
290
290
  this.#offlineMessageBuffer.length = 0;
291
291
 
292
+ await this.#deleteConsumer();
293
+
292
294
  this.#natsClient.close();
293
295
  }else{
294
296
  this.#log("Null / undefined socket, cannot close connection");
@@ -299,10 +301,9 @@ export class Realtime {
299
301
  * Start consumers for topics initialized by user
300
302
  */
301
303
  async #subscribeToTopics(){
302
- this.#topicMap.forEach(async (topic) => {
303
- // Subscribe to stream
304
- await this.#startConsumer(topic);
305
- });
304
+ if(this.#topicMap.length > 0){
305
+ await this.#startConsumer();
306
+ }
306
307
  }
307
308
 
308
309
  /**
@@ -323,8 +324,6 @@ export class Realtime {
323
324
  this.#topicMap = this.#topicMap.filter(item => item !== topic);
324
325
 
325
326
  delete this.#event_func[topic];
326
-
327
- return await this.#deleteConsumer(topic);
328
327
  }
329
328
 
330
329
  /**
@@ -467,6 +466,10 @@ export class Realtime {
467
466
  throw new Error(`Expected $topic type -> string. Instead receieved -> ${typeof topic}`);
468
467
  }
469
468
 
469
+ if(!this.isTopicValid(topic)){
470
+ throw new Error("Invalid topic, use isTopicValid($topic) to validate topic")
471
+ }
472
+
470
473
  if(start == undefined || start == null){
471
474
  throw new Error(`$start must be provided. $start is => ${start}`)
472
475
  }
@@ -568,39 +571,49 @@ export class Realtime {
568
571
  * @param {string} topic
569
572
  */
570
573
  async #startConsumer(topic){
574
+ if(this.#consumer != null){
575
+ return;
576
+ }
577
+
571
578
  this.#log(`Starting consumer for topic: ${topic}_${uuidv4()}`)
572
579
 
573
580
  var opts = {
574
- name: `${topic}_${uuidv4()}`,
575
- filter_subjects: [this.#getStreamTopic(topic), this.#getStreamTopic(topic) + "_presence"],
581
+ name: `${uuidv4()}`,
582
+ filter_subjects: [this.#getStreamTopic(">")],
576
583
  replay_policy: ReplayPolicy.Instant,
577
584
  opt_start_time: new Date(),
578
585
  ack_policy: AckPolicy.Explicit,
579
586
  delivery_policy: DeliverPolicy.New
580
587
  }
581
588
 
582
- const consumer = await this.#jetstream.consumers.get(this.#getStreamName(), opts);
589
+ this.#consumer = await this.#jetstream.consumers.get(this.#getStreamName(), opts);
583
590
  this.#log(this.#topicMap)
584
591
 
585
- this.#consumerMap[topic] = consumer;
586
-
587
- await consumer.consume({
592
+ await this.#consumer.consume({
588
593
  callback: async (msg) => {
589
594
  try{
590
595
  const now = Date.now();
591
596
  this.#log("Decoding msgpack message...")
592
597
  var data = decode(msg.data);
593
598
 
594
- var room = data.room;
599
+ var room = this.#stripStreamHash(msg.subject);
595
600
 
596
601
  this.#log(data);
597
602
 
598
603
  // 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
+ if (data.client_id != this.#getClientId()){
605
+ var topics = this.#getCallbackTopics(room);
606
+ this.#log(topics)
607
+
608
+ for(let i = 0; i < topics.length; i++){
609
+ var top = topics[i];
610
+
611
+ this.#event_func[top]({
612
+ "id": data.id,
613
+ "topic": room,
614
+ "data": data.message
615
+ });
616
+ }
604
617
  }
605
618
 
606
619
  msg.ack();
@@ -608,7 +621,7 @@ export class Realtime {
608
621
  await this.#logLatency(now, data);
609
622
  }catch(err){
610
623
  this.#log("Consumer err " + err);
611
- msg.nack(5000);
624
+ msg.nak(5000);
612
625
  }
613
626
  }
614
627
  });
@@ -619,19 +632,15 @@ export class Realtime {
619
632
  * Deletes consumer
620
633
  * @param {string} topic
621
634
  */
622
- async #deleteConsumer(topic){
623
- const consumer = this.#consumerMap[topic]
624
-
635
+ async #deleteConsumer(){
625
636
  var del = false;
626
637
 
627
- if (consumer != null && consumer != undefined){
628
- del = await consumer.delete();
638
+ if (this.#consumer != null && this.#consumer != undefined){
639
+ del = await this.#consumer.delete();
629
640
  }else{
630
641
  del = false
631
642
  }
632
643
 
633
- delete this.#consumerMap[topic];
634
-
635
644
  return del;
636
645
  }
637
646
 
@@ -734,7 +743,7 @@ export class Realtime {
734
743
  var arrayCheck = ![CONNECTED, DISCONNECTED, RECONNECT, this.#RECONNECTED,
735
744
  this.#RECONNECTING, this.#RECONN_FAIL, MESSAGE_RESEND, SERVER_DISCONNECT].includes(topic);
736
745
 
737
- const TOPIC_REGEX = /^(?!\$)[A-Za-z0-9_,.*>\$-]+$/;
746
+ const TOPIC_REGEX = /^(?!.*\$)(?:[A-Za-z0-9_*~-]+(?:\.[A-Za-z0-9_*~-]+)*(?:\.>)?|>)$/u;
738
747
 
739
748
  var spaceStarCheck = !topic.includes(" ") && TOPIC_REGEX.test(topic);
740
749
 
@@ -791,6 +800,91 @@ export class Realtime {
791
800
  }
792
801
  }
793
802
 
803
+ #stripStreamHash(topic){
804
+ return topic.replace(`${this.topicHash}.`, "")
805
+ }
806
+
807
+ #getCallbackTopics(topic){
808
+ var validTopics = [];
809
+
810
+ var topicPatterns = Object.keys(this.#event_func);
811
+
812
+ for(let i = 0; i < topicPatterns.length; i++){
813
+ var pattern = topicPatterns[i];
814
+
815
+ if([CONNECTED, RECONNECT, MESSAGE_RESEND, DISCONNECTED, SERVER_DISCONNECT].includes(pattern)){
816
+ continue;
817
+ }
818
+
819
+ var match = this.#topicPatternMatcher(pattern, topic);
820
+
821
+ if(match){
822
+ validTopics.push(pattern)
823
+ }
824
+ }
825
+
826
+ return validTopics;
827
+
828
+ }
829
+
830
+ #topicPatternMatcher(patternA, patternB) {
831
+ const a = patternA.split(".");
832
+ const b = patternB.split(".");
833
+
834
+ let i = 0, j = 0; // cursors in a & b
835
+ let starAi = -1, starAj = -1; // last '>' position in A and the token count it has consumed
836
+ let starBi = -1, starBj = -1; // same for pattern B
837
+
838
+ while (i < a.length || j < b.length) {
839
+ const tokA = a[i];
840
+ const tokB = b[j];
841
+
842
+ /*──────────── literal match or single‑token wildcard on either side ────────────*/
843
+ const singleWildcard =
844
+ (tokA === "*" && j < b.length) ||
845
+ (tokB === "*" && i < a.length);
846
+
847
+ if (
848
+ (tokA !== undefined && tokA === tokB) ||
849
+ singleWildcard
850
+ ) {
851
+ i++; j++;
852
+ continue;
853
+ }
854
+
855
+ /*────────────────── multi‑token wildcard ">" — must be **final** ───────────────*/
856
+ if (tokA === ">") {
857
+ if (i !== a.length - 1) return false; // '>' not in last position → invalid
858
+ if (j >= b.length) return false; // must consume at least one token
859
+ starAi = i++; // remember where '>' is
860
+ starAj = ++j; // gobble one token from B
861
+ continue;
862
+ }
863
+ if (tokB === ">") {
864
+ if (j !== b.length - 1) return false; // same rule for patternB
865
+ if (i >= a.length) return false;
866
+ starBi = j++;
867
+ starBj = ++i;
868
+ continue;
869
+ }
870
+
871
+ /*───────────────────────────── back‑track using last '>' ───────────────────────*/
872
+ if (starAi !== -1) { // let patternA's '>' absorb one more token of B
873
+ j = ++starAj;
874
+ continue;
875
+ }
876
+ if (starBi !== -1) { // let patternB's '>' absorb one more token of A
877
+ i = ++starBj;
878
+ continue;
879
+ }
880
+
881
+ /*────────────────────────────────── dead‑end ───────────────────────────────────*/
882
+ return false;
883
+ }
884
+
885
+ return true;
886
+ }
887
+
794
888
  sleep(ms) {
795
889
  return new Promise(resolve => setTimeout(resolve, ms));
796
890
  }
@@ -929,6 +1023,14 @@ ${secret}
929
1023
  return null;
930
1024
  }
931
1025
  }
1026
+
1027
+ testPatternMatcher(){
1028
+ if(process.env.NODE_ENV == "test"){
1029
+ return this.#topicPatternMatcher.bind(this)
1030
+ }else{
1031
+ return null;
1032
+ }
1033
+ }
932
1034
  }
933
1035
 
934
1036
  export const CONNECTED = "CONNECTED";
package/tests/test.js CHANGED
@@ -449,36 +449,38 @@ test("Test isTopicValidMethod()", () => {
449
449
  });
450
450
 
451
451
  unreservedInvalidTopics = [
452
- '$internal', // starts with $
453
- 'hello world', // space
454
- 'topic/', // slash
455
- 'name?', // ?
456
- 'foo#bar', // #
457
- 'bar.baz!', // !
458
- ' space', // leading space
459
- 'tab\tchar', // tab
460
- 'line\nbreak', // newline
461
- 'comma ,', // space + comma
462
- '', // empty string
463
- 'bad|pipe', // |
464
- 'semi;colon', // ;
465
- 'colon:here', // :
466
- "quote's", // '
467
- '"doublequote"', // "
468
- 'brackets[]', // []
469
- 'brace{}', // {}
470
- 'paren()', // ()
471
- 'plus+sign', // +
472
- 'eq=val', // =
473
- 'gt>lt<', // < mixed with >
474
- 'percent%', // %
475
- 'caret^', // ^
476
- 'ampersand&', // &
477
- 'back\\slash', // backslash
478
- '中文字符', // non‑ASCII
479
- '👍emoji', // emoji
480
- 'foo\rbar', // carriage return
481
- 'end ' // trailing space
452
+ "$foo",
453
+ "foo$",
454
+ "foo.$.bar",
455
+ "foo..bar",
456
+ ".foo",
457
+ "foo.",
458
+ "foo.>.bar",
459
+ ">foo",
460
+ "foo>bar",
461
+ "foo.>bar",
462
+ "foo.bar.>.",
463
+ "foo bar",
464
+ "foo/bar",
465
+ "foo#bar",
466
+ "",
467
+ " ",
468
+ "..",
469
+ ".>",
470
+ "foo..",
471
+ ".",
472
+ ">.",
473
+ "foo,baz",
474
+ "αbeta",
475
+ "foo|bar",
476
+ "foo;bar",
477
+ "foo:bar",
478
+ "foo%bar",
479
+ "foo.*.>.bar",
480
+ "foo.*.>.",
481
+ "foo.*..bar",
482
+ "foo.>.bar",
483
+ "foo>"
482
484
  ];
483
485
 
484
486
  unreservedInvalidTopics.forEach(topic => {
@@ -487,40 +489,41 @@ test("Test isTopicValidMethod()", () => {
487
489
  });
488
490
 
489
491
  var unreservedValidTopics = [
490
- 'Orders',
491
- 'customer_123',
492
- 'foo-bar',
493
- 'a,b,c',
494
- '*',
495
- 'foo>*',
496
- 'hello$world',
497
- 'topic.123',
498
- 'ABC_def-ghi',
499
- 'data_stream_2025',
500
- 'NODE*',
501
- 'pubsub>events',
502
- 'log,metric,error',
503
- 'X123_Y456',
504
- 'multi.step.topic',
505
- 'batch-process',
506
- 'sensor1_data',
507
- 'finance$Q2',
508
- 'alpha,beta,gamma',
509
- 'Z9_Y8-X7',
510
- 'config>*',
511
- 'route-map',
512
- 'STATS_2025-07',
513
- 'msg_queue*',
514
- 'update>patch',
515
- 'pipeline_v2',
516
- 'FOO$BAR$BAZ',
517
- 'user.profile',
518
- 'id_001-xyz',
519
- 'event_queue>'
492
+ "foo",
493
+ "foo.bar",
494
+ "foo.bar.baz",
495
+ "*",
496
+ "foo.*",
497
+ "*.bar",
498
+ "foo.*.baz",
499
+ ">",
500
+ "foo.>",
501
+ "foo.bar.>",
502
+ "*.*.>",
503
+ "alpha_beta",
504
+ "alpha-beta",
505
+ "alpha~beta",
506
+ "abc123",
507
+ "123abc",
508
+ "~",
509
+ "alpha.*.>",
510
+ "alpha.*",
511
+ "alpha.*.*",
512
+ "-foo",
513
+ "foo_bar-baz~qux",
514
+ "A.B.C",
515
+ "sensor.temperature",
516
+ "metric.cpu.load",
517
+ "foo.*.*",
518
+ "foo.*.>",
519
+ "foo_bar.*",
520
+ "*.*",
521
+ "metrics.>"
520
522
  ];
521
523
 
522
524
  unreservedValidTopics.forEach(topic => {
523
525
  var valid = realTimeEnabled.isTopicValid(topic);
526
+ console.log(topic)
524
527
  assert.strictEqual(valid, true);
525
528
  });
526
529
  });
@@ -579,4 +582,77 @@ test("History test", async () => {
579
582
  },
580
583
  new Error("$start must be a Date object"),
581
584
  "Expected error was not thrown");
585
+ })
586
+
587
+ test("Pattern matcher test", async () => {
588
+ var cases = [
589
+ ["foo", "foo", true], // 1
590
+ ["foo", "bar", false], // 2
591
+ ["foo.*", "foo.bar", true], // 3
592
+ ["foo.bar", "foo.*", true], // 4
593
+ ["*", "token", true], // 5
594
+ ["*", "*", true], // 6
595
+ ["foo.*", "foo.bar.baz", false], // 7
596
+ ["foo.>", "foo.bar.baz", true], // 8
597
+ ["foo.>", "foo", false], // 9 (zero‑token '>' now invalid)
598
+ ["foo.bar.baz", "foo.>", true], // 10
599
+ ["foo.bar.>", "foo.bar", false], // 11
600
+ ["foo", "foo.>", false], // 12
601
+ ["foo.*.>", "foo.bar.baz.qux", true], // 13
602
+ ["foo.*.baz", "foo.bar.>", true], // 14
603
+ ["alpha.*", "beta.gamma", false], // 15
604
+ ["alpha.beta", "alpha.*.*", false], // 16
605
+ ["foo.>.bar", "foo.any.bar", false], // 17 ('>' mid‑pattern)
606
+ [">", "foo.bar", true], // 18
607
+ [">", ">", true], // 19
608
+ ["*", ">", true], // 20
609
+ ["*.>", "foo.bar", true], // 21
610
+ ["*.*.*", "a.b.c", true], // 22
611
+ ["*.*.*", "a.b", false], // 23
612
+ ["a.b.c.d.e", "a.b.c.d.e", true], // 24
613
+ ["a.b.c.d.e", "a.b.c.d.f", false], // 25
614
+ ["a.b.*.d", "a.b.c.d", true], // 26
615
+ ["a.b.*.d", "a.b.c.e", false], // 27
616
+ ["a.b.>", "a.b", false], // 28
617
+ ["a.b", "a.b.c.d.>", false], // 29
618
+ ["a.b.>.c", "a.b.x.c", false], // 30
619
+ ["a.*.*", "a.b", false], // 31
620
+ ["a.*", "a.b.c", false], // 32
621
+ ["metrics.cpu.load", "metrics.*.load", true], // 33
622
+ ["metrics.cpu.load", "metrics.cpu.*", true], // 34
623
+ ["metrics.cpu.load", "metrics.>.load", false], // 35
624
+ ["metrics.>", "metrics", false], // 36
625
+ ["metrics.>", "othermetrics.cpu", false], // 37
626
+ ["*.*.>", "a.b", false], // 38
627
+ ["*.*.>", "a.b.c.d", true], // 39
628
+ ["a.b.c", "*.*.*", true], // 40
629
+ ["a.b.c", "*.*", false], // 41
630
+ ["alpha.*.>", "alpha", false], // 42
631
+ ["alpha.*.>", "alpha.beta", false], // 43
632
+ ["alpha.*.>", "alpha.beta.gamma", true], // 44
633
+ ["alpha.*.>", "beta.alpha.gamma", false], // 45
634
+ ["foo-bar_baz", "foo-bar_baz", true], // 46
635
+ ["foo-bar_*", "foo-bar_123", false], // 47 ( '*' here is literal )
636
+ ["foo-bar_*", "foo-bar_*", true], // 48
637
+ ["order-*", "order-123", false], // 49
638
+ ["hello.hey.*", "hello.hey.>", true] // 50
639
+ ];
640
+
641
+ var realtime = new Realtime({
642
+ api_key: process.env.AUTH_JWT,
643
+ secret: process.env.AUTH_SECRET
644
+ });
645
+
646
+ var patternMatcher = realtime.testPatternMatcher();
647
+
648
+ cases.forEach(testCase => {
649
+ var tokenA = testCase[0];
650
+ var tokenB = testCase[1];
651
+ var expectedResult = testCase[2];
652
+
653
+ console.log(`${tokenA} ⇆ ${tokenB} → ${expectedResult}`)
654
+
655
+ var result = patternMatcher(tokenA, tokenB)
656
+ assert.strictEqual(expectedResult, result)
657
+ });
582
658
  })