relayx-webjs 1.0.1 → 1.0.3

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
@@ -5,7 +5,8 @@
5
5
  "scripts": {
6
6
  "dev": "vite",
7
7
  "build": "vite build",
8
- "preview": "vite preview"
8
+ "preview": "vite preview",
9
+ "preview-proxy": "PROXY=true vite preview"
9
10
  },
10
11
  "dependencies": {
11
12
  "react": "^18.2.0",
@@ -0,0 +1,111 @@
1
+ import { Realtime, CONNECTED, RECONNECT, DISCONNECTED, MESSAGE_RESEND } from "../../realtime/realtime.js"
2
+ import * as readline from 'readline';
3
+
4
+ const rl = readline.createInterface({
5
+ input: process.stdin,
6
+ output: process.stdout
7
+ });
8
+
9
+ async function run(){
10
+ var realtime = new Realtime({
11
+ api_key: process.env.AUTH_JWT,
12
+ secret: process.env.AUTH_SECRET
13
+ });
14
+ await realtime.init(false, {
15
+ max_retries: 2,
16
+ debug: true
17
+ });
18
+
19
+ realtime.on(CONNECTED, async () => {
20
+ console.log("[IMPL] => CONNECTED!");
21
+ });
22
+
23
+ realtime.on(RECONNECT, (status) => {
24
+ console.log(`[IMPL] RECONNECT => ${status}`)
25
+ });
26
+
27
+ realtime.on(DISCONNECTED, () => {
28
+ console.log(`[IMPL] DISONNECT`)
29
+ });
30
+
31
+ await realtime.on("power-telemetry", (data) => {
32
+ console.log("power-telemetry", data);
33
+ });
34
+
35
+ await realtime.on("hello.>", async (data) => {
36
+ console.log("hello.>", data);
37
+ });
38
+
39
+ // await realtime.on("hello.hey.*", (data) => {
40
+ // console.log("hell.hey.*", data);
41
+ // });
42
+
43
+ // await realtime.on("hello.hey.>", (data) => {
44
+ // console.log("hello.hey.>", data);
45
+ // });
46
+
47
+ realtime.on(MESSAGE_RESEND, (data) => {
48
+ console.log(`[MSG RESEND] => ${data}`)
49
+ });
50
+
51
+ rl.on('line', async (input) => {
52
+ console.log(`You entered: ${input}`);
53
+
54
+ if(input == "exit"){
55
+ var output = await realtime.off("hello");
56
+ console.log(output);
57
+
58
+ realtime.close();
59
+
60
+ process.exit();
61
+ }else if(input == "history"){
62
+ rl.question("topic: ", async (topic) => {
63
+ var start = new Date();
64
+ var past = start.setDate(start.getDate() - 4)
65
+ var pastDate = new Date(past)
66
+
67
+ var end = new Date();
68
+ var past = end.setDate(end.getDate())
69
+ var endDate = new Date(past)
70
+
71
+ var history = await realtime.history(topic, pastDate)
72
+ console.log(history)
73
+ })
74
+ }else if(input == "off"){
75
+ rl.question("topic to off(): ", async (topic) => {
76
+ await realtime.off(topic);
77
+ console.log("off() executed")
78
+ })
79
+
80
+
81
+ }else if(input == "close"){
82
+ realtime.close();
83
+ console.log("Connection closed");
84
+ }else if(input == "init"){
85
+ await realtime.connect()
86
+ }else if(input == "on"){
87
+ rl.question("topic: ", async (topic) => {
88
+ await realtime.on(topic, (data) => {
89
+ console.log(topic, data);
90
+ });
91
+ })
92
+ }else{
93
+ rl.question("topic: ", async (topic) => {
94
+ var output = await realtime.publish(topic, input);
95
+ })
96
+ }
97
+ });
98
+
99
+ realtime.connect();
100
+
101
+ process.on('SIGINT', async () => {
102
+ console.log('Keyboard interrupt detected (Ctrl+C). Cleaning up...');
103
+ // Perform any necessary cleanup here
104
+
105
+ // Exit the process
106
+ process.exit();
107
+ });
108
+
109
+ }
110
+
111
+ await run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relayx-webjs",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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",
@@ -0,0 +1,20 @@
1
+ import dns from 'node:dns';
2
+
3
+ export function initDNSSpoof(){
4
+ const originalLookup = dns.lookup;
5
+
6
+ // Override for the whole process
7
+ dns.lookup = function patchedLookup(hostname, options, callback) {
8
+
9
+ // ── Our one special case ──────────────────────────────────
10
+ if (hostname === 'api2.relay-x.io') {
11
+ // Map to loop‑back; family 4 avoids ::1
12
+ return process.nextTick(() =>
13
+ callback(null, [{address: '127.0.0.1', family: 4}])
14
+ );
15
+ }
16
+
17
+ // Anything else → real DNS
18
+ return originalLookup.call(dns, hostname, options, callback);
19
+ };
20
+ }
@@ -2,6 +2,7 @@ import { connect, JSONCodec, Events, DebugEvents, AckPolicy, ReplayPolicy, creds
2
2
  import { DeliverPolicy, jetstream } from "@nats-io/jetstream";
3
3
  import { encode, decode } from "@msgpack/msgpack";
4
4
  import { v4 as uuidv4 } from 'uuid';
5
+ import { initDNSSpoof } from "./dns_change.js";
5
6
 
6
7
  export class Realtime {
7
8
 
@@ -11,6 +12,7 @@ export class Realtime {
11
12
  #codec = JSONCodec();
12
13
  #jetstream = null;
13
14
  #consumerMap = {};
15
+ #consumer = null;
14
16
 
15
17
  #event_func = {};
16
18
  #topicMap = [];
@@ -20,6 +22,8 @@ export class Realtime {
20
22
  #RECONNECTED = "RECONNECTED";
21
23
  #RECONN_FAIL = "RECONN_FAIL";
22
24
 
25
+ #reservedSystemTopics = [CONNECTED, DISCONNECTED, RECONNECT, this.#RECONNECTED, this.#RECONNECTING, this.#RECONN_FAIL, MESSAGE_RESEND, SERVER_DISCONNECT];
26
+
23
27
  setRemoteUserAttempts = 0;
24
28
  setRemoteUserRetries = 5;
25
29
 
@@ -39,6 +43,8 @@ export class Realtime {
39
43
 
40
44
  #maxPublishRetries = 5;
41
45
 
46
+ #connectCalled = false;
47
+
42
48
  constructor(config){
43
49
  if(typeof config != "object"){
44
50
  throw new Error("Realtime($config). $config not object => {}")
@@ -94,10 +100,13 @@ export class Realtime {
94
100
  if(arguments[0] instanceof Object){
95
101
  opts = arguments[0];
96
102
  staging = false;
97
- }else{
103
+ }else if(typeof arguments[0] == "boolean"){
98
104
  opts = {};
99
105
  staging = arguments[0];
100
106
  this.#log(staging)
107
+ }else{
108
+ opts = {};
109
+ staging = false
101
110
  }
102
111
  }else{
103
112
  staging = false;
@@ -107,23 +116,28 @@ export class Realtime {
107
116
  this.staging = staging;
108
117
  this.opts = opts;
109
118
 
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
- [
119
+ if(process.env.PROXY){
120
+ this.#baseUrl = ["wss://api2.relay-x.io:8666"];
121
+ initDNSSpoof();
122
+ }else{
123
+ if (staging !== undefined || staging !== null){
124
+ this.#baseUrl = staging ? [
125
+ "nats://0.0.0.0:4421",
126
+ "nats://0.0.0.0:4422",
127
+ "nats://0.0.0.0:4423"
128
+ ] :
129
+ [
130
+ `wss://api.relay-x.io:4421`,
131
+ `wss://api.relay-x.io:4422`,
132
+ `wss://api.relay-x.io:4423`
133
+ ];
134
+ }else{
135
+ this.#baseUrl = [
117
136
  `wss://api.relay-x.io:4421`,
118
137
  `wss://api.relay-x.io:4422`,
119
138
  `wss://api.relay-x.io:4423`
120
139
  ];
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
- ];
140
+ }
127
141
  }
128
142
 
129
143
  this.#log(this.#baseUrl);
@@ -163,6 +177,10 @@ export class Realtime {
163
177
  * Connects to the relay network
164
178
  */
165
179
  async connect(){
180
+ if(this.#connectCalled){
181
+ return;
182
+ }
183
+
166
184
  this.SEVER_URL = this.#baseUrl;
167
185
 
168
186
  var credsFile = this.#getUserCreds(this.api_key, this.secret)
@@ -177,7 +195,7 @@ export class Realtime {
177
195
  maxReconnectAttempts: 1200,
178
196
  reconnectTimeWait: 1000,
179
197
  authenticator: credsAuth,
180
- token: this.api_key,
198
+ token: this.api_key
181
199
  });
182
200
 
183
201
  this.#jetstream = await jetstream(this.#natsClient);
@@ -185,6 +203,7 @@ export class Realtime {
185
203
  await this.#getNameSpace()
186
204
 
187
205
  this.connected = true;
206
+ this.#connectCalled = true;
188
207
  }catch(err){
189
208
  this.#log("ERR")
190
209
  this.#log(err);
@@ -195,17 +214,13 @@ export class Realtime {
195
214
  if (this.connected == true){
196
215
  this.#log("Connected to server!");
197
216
 
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
217
  this.#natsClient.closed().then(() => {
206
218
  this.#log("the connection closed!");
207
219
 
208
220
  this.#offlineMessageBuffer.length = 0;
221
+ this.connected = false;
222
+ this.reconnecting = false;
223
+ this.#connectCalled = false;
209
224
 
210
225
  if (DISCONNECTED in this.#event_func){
211
226
  if (this.#event_func[DISCONNECTED] !== null || this.#event_func[DISCONNECTED] !== undefined){
@@ -223,13 +238,6 @@ export class Realtime {
223
238
  this.#log(`client disconnected - ${s.data}`);
224
239
 
225
240
  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
241
  break;
234
242
  case Events.LDM:
235
243
  this.#log("client has been requested to reconnect");
@@ -259,6 +267,7 @@ export class Realtime {
259
267
  this.#log("client is attempting to reconnect");
260
268
 
261
269
  this.reconnecting = true;
270
+ this.connected = false;
262
271
 
263
272
  if(RECONNECT in this.#event_func && this.reconnecting){
264
273
  this.#event_func[RECONNECT](this.#RECONNECTING);
@@ -276,19 +285,29 @@ export class Realtime {
276
285
  // Subscribe to topics
277
286
  this.#subscribeToTopics();
278
287
  this.#log("Subscribed to topics");
288
+
289
+ // Callback on client side
290
+ if (CONNECTED in this.#event_func){
291
+ if (this.#event_func[CONNECTED] !== null || this.#event_func[CONNECTED] !== undefined){
292
+ this.#event_func[CONNECTED]()
293
+ }
294
+ }
279
295
  }
280
296
  }
281
297
 
282
298
  /**
283
299
  * Closes connection
284
300
  */
285
- close(){
301
+ async close(){
286
302
  if(this.#natsClient !== null){
287
303
  this.reconnected = false;
288
304
  this.disconnected = true;
305
+ this.#connectCalled = false;
289
306
 
290
307
  this.#offlineMessageBuffer.length = 0;
291
308
 
309
+ await this.#deleteAllConsumers();
310
+
292
311
  this.#natsClient.close();
293
312
  }else{
294
313
  this.#log("Null / undefined socket, cannot close connection");
@@ -304,6 +323,17 @@ export class Realtime {
304
323
  await this.#startConsumer(topic);
305
324
  });
306
325
  }
326
+
327
+ /**
328
+ * Delete consumers for topics initialized by user
329
+ */
330
+ async #deleteAllConsumers(){
331
+ for(let i = 0; i < this.#topicMap.length; i++){
332
+ let topic = this.#topicMap[i];
333
+
334
+ await this.#deleteConsumer(topic);
335
+ }
336
+ }
307
337
 
308
338
  /**
309
339
  * Deletes reference to user defined event callback.
@@ -350,31 +380,23 @@ export class Realtime {
350
380
  throw new Error(`Expected $topic type -> string. Instead receieved -> ${typeof topic}`);
351
381
  }
352
382
 
353
- if(!(topic in this.#event_func)){
354
- this.#event_func[topic] = func;
355
- }else{
383
+ if(topic in this.#event_func || this.#topicMap.includes(topic)){
356
384
  return false
357
385
  }
358
386
 
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
- }
387
+ this.#event_func[topic] = func;
366
388
 
367
- throw new Error("Invalid topic, use isTopicValid($topic) to validate topic")
368
- }
389
+ if (!this.#reservedSystemTopics.includes(topic)){
390
+ if(!this.isTopicValid(topic)){
391
+ throw new Error("Invalid topic, use isTopicValid($topic) to validate topic")
392
+ }
369
393
 
370
- if(!this.#topicMap.includes(topic)){
371
- this.#topicMap.push(topic);
372
- }
394
+ this.#topicMap.push(topic);
373
395
 
374
- if(this.connected){
375
- // Connected we need to create a topic in a stream
376
- await this.#startConsumer(topic);
377
- }
396
+ if(this.connected){
397
+ // Connected we need to create a topic in a stream
398
+ await this.#startConsumer(topic);
399
+ }
378
400
  }
379
401
 
380
402
  return true;
@@ -404,7 +426,7 @@ export class Realtime {
404
426
  throw new Error("Invalid topic, use isTopicValid($topic) to validate topic")
405
427
  }
406
428
 
407
- if(!this.#isMessageValid(data)){
429
+ if(!this.isMessageValid(data)){
408
430
  throw new Error("$message must be JSON, string or number")
409
431
  }
410
432
 
@@ -419,15 +441,9 @@ export class Realtime {
419
441
  "start": Date.now()
420
442
  }
421
443
 
422
- this.#log("Encoding message via msg pack...")
423
- var encodedMessage = encode(message);
424
-
425
444
  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
- }
445
+ this.#log("Encoding message via msg pack...")
446
+ var encodedMessage = encode(message);
431
447
 
432
448
  this.#log(`Publishing to topic => ${this.#getStreamTopic(topic)}`)
433
449
 
@@ -454,7 +470,6 @@ export class Realtime {
454
470
  * @param {string} topic
455
471
  */
456
472
  async history(topic, start, end){
457
- this.#log(start)
458
473
  if(topic == null || topic == undefined){
459
474
  throw new Error("$topic is null or undefined");
460
475
  }
@@ -467,6 +482,10 @@ export class Realtime {
467
482
  throw new Error(`Expected $topic type -> string. Instead receieved -> ${typeof topic}`);
468
483
  }
469
484
 
485
+ if(!this.isTopicValid(topic)){
486
+ throw new Error("Invalid topic, use isTopicValid($topic) to validate topic")
487
+ }
488
+
470
489
  if(start == undefined || start == null){
471
490
  throw new Error(`$start must be provided. $start is => ${start}`)
472
491
  }
@@ -487,11 +506,16 @@ export class Realtime {
487
506
  end = end.toISOString();
488
507
  }
489
508
 
509
+ if(!this.connected){
510
+ return [];
511
+ }
512
+
490
513
  var opts = {
491
- name: `${topic}_${uuidv4()}_history`,
514
+ name: `webjs_${topic}_${uuidv4()}_history_consumer`,
492
515
  filter_subjects: [this.#getStreamTopic(topic)],
493
516
  replay_policy: ReplayPolicy.Instant,
494
517
  opt_start_time: start,
518
+ delivery_policy: DeliverPolicy.StartTime,
495
519
  ack_policy: AckPolicy.Explicit,
496
520
  }
497
521
 
@@ -520,7 +544,12 @@ export class Realtime {
520
544
  var data = decode(msg.data);
521
545
  this.#log(data);
522
546
 
523
- history.push(data.message);
547
+ history.push({
548
+ "id": data.id,
549
+ "topic": data.room,
550
+ "message": data.message,
551
+ "timestamp": msg.timestamp
552
+ });
524
553
  }
525
554
 
526
555
  var del = await consumer.delete();
@@ -568,11 +597,11 @@ export class Realtime {
568
597
  * @param {string} topic
569
598
  */
570
599
  async #startConsumer(topic){
571
- this.#log(`Starting consumer for topic: ${topic}_${uuidv4()}`)
600
+ const consumerName = `webjs_${topic}_${uuidv4()}_consumer`;
572
601
 
573
602
  var opts = {
574
- name: `${topic}_${uuidv4()}`,
575
- filter_subjects: [this.#getStreamTopic(topic), this.#getStreamTopic(topic) + "_presence"],
603
+ name: consumerName,
604
+ filter_subjects: [this.#getStreamTopic(topic)],
576
605
  replay_policy: ReplayPolicy.Instant,
577
606
  opt_start_time: new Date(),
578
607
  ack_policy: AckPolicy.Explicit,
@@ -588,19 +617,25 @@ export class Realtime {
588
617
  callback: async (msg) => {
589
618
  try{
590
619
  const now = Date.now();
620
+ msg.working()
591
621
  this.#log("Decoding msgpack message...")
592
622
  var data = decode(msg.data);
593
623
 
594
- var room = data.room;
624
+ var msgTopic = this.#stripStreamHash(msg.subject);
595
625
 
596
626
  this.#log(data);
597
627
 
598
628
  // 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
- });
629
+ if (data.client_id != this.#getClientId()){
630
+ var topicMatch = this.#topicPatternMatcher(topic, msgTopic)
631
+
632
+ if(topicMatch){
633
+ this.#event_func[topic]({
634
+ "id": data.id,
635
+ "topic": msgTopic,
636
+ "data": data.message
637
+ });
638
+ }
604
639
  }
605
640
 
606
641
  msg.ack();
@@ -608,7 +643,7 @@ export class Realtime {
608
643
  await this.#logLatency(now, data);
609
644
  }catch(err){
610
645
  this.#log("Consumer err " + err);
611
- msg.nack(5000);
646
+ msg.nak(5000);
612
647
  }
613
648
  }
614
649
  });
@@ -620,6 +655,7 @@ export class Realtime {
620
655
  * @param {string} topic
621
656
  */
622
657
  async #deleteConsumer(topic){
658
+ this.#log(topic)
623
659
  const consumer = this.#consumerMap[topic]
624
660
 
625
661
  var del = false;
@@ -641,11 +677,6 @@ export class Realtime {
641
677
  return;
642
678
  }
643
679
 
644
- if(this.#latency.length >= 100){
645
- this.#log("Latency array is full, skipping log");
646
- return;
647
- }
648
-
649
680
  const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
650
681
 
651
682
  this.#log(`Timezone: ${timeZone}`);
@@ -662,7 +693,7 @@ export class Realtime {
662
693
  this.#latencyPush = setTimeout(async () => {
663
694
  this.#log("setTimeout called");
664
695
 
665
- if(this.#latency.length > 0){
696
+ if(this.#latency.length > 0 && this.connected && !this.#isSendingLatency){
666
697
  this.#log("Push from setTimeout")
667
698
  await this.#pushLatencyData({
668
699
  timezone: timeZone,
@@ -675,7 +706,7 @@ export class Realtime {
675
706
  }, 30000);
676
707
  }
677
708
 
678
- if(this.#latency.length == 100 && !this.#isSendingLatency){
709
+ if(this.#latency.length >= 100 && !this.#isSendingLatency){
679
710
  this.#log("Push from Length Check: " + this.#latency.length);
680
711
  await this.#pushLatencyData({
681
712
  timezone: timeZone,
@@ -731,10 +762,9 @@ export class Realtime {
731
762
  */
732
763
  isTopicValid(topic){
733
764
  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);
765
+ var arrayCheck = !this.#reservedSystemTopics.includes(topic);
736
766
 
737
- const TOPIC_REGEX = /^(?!\$)[A-Za-z0-9_,.*>\$-]+$/;
767
+ const TOPIC_REGEX = /^(?!.*\$)(?:[A-Za-z0-9_*~-]+(?:\.[A-Za-z0-9_*~-]+)*(?:\.>)?|>)$/u;
738
768
 
739
769
  var spaceStarCheck = !topic.includes(" ") && TOPIC_REGEX.test(topic);
740
770
 
@@ -744,7 +774,7 @@ export class Realtime {
744
774
  }
745
775
  }
746
776
 
747
- #isMessageValid(message){
777
+ isMessageValid(message){
748
778
  if(message == null || message == undefined){
749
779
  throw new Error("$message cannot be null / undefined")
750
780
  }
@@ -791,6 +821,91 @@ export class Realtime {
791
821
  }
792
822
  }
793
823
 
824
+ #stripStreamHash(topic){
825
+ return topic.replace(`${this.topicHash}.`, "")
826
+ }
827
+
828
+ #getCallbackTopics(topic){
829
+ var validTopics = [];
830
+
831
+ var topicPatterns = Object.keys(this.#event_func);
832
+
833
+ for(let i = 0; i < topicPatterns.length; i++){
834
+ var pattern = topicPatterns[i];
835
+
836
+ if([CONNECTED, RECONNECT, MESSAGE_RESEND, DISCONNECTED, SERVER_DISCONNECT].includes(pattern)){
837
+ continue;
838
+ }
839
+
840
+ var match = this.#topicPatternMatcher(pattern, topic);
841
+
842
+ if(match){
843
+ validTopics.push(pattern)
844
+ }
845
+ }
846
+
847
+ return validTopics;
848
+
849
+ }
850
+
851
+ #topicPatternMatcher(patternA, patternB) {
852
+ const a = patternA.split(".");
853
+ const b = patternB.split(".");
854
+
855
+ let i = 0, j = 0; // cursors in a & b
856
+ let starAi = -1, starAj = -1; // last '>' position in A and the token count it has consumed
857
+ let starBi = -1, starBj = -1; // same for pattern B
858
+
859
+ while (i < a.length || j < b.length) {
860
+ const tokA = a[i];
861
+ const tokB = b[j];
862
+
863
+ /*──────────── literal match or single‑token wildcard on either side ────────────*/
864
+ const singleWildcard =
865
+ (tokA === "*" && j < b.length) ||
866
+ (tokB === "*" && i < a.length);
867
+
868
+ if (
869
+ (tokA !== undefined && tokA === tokB) ||
870
+ singleWildcard
871
+ ) {
872
+ i++; j++;
873
+ continue;
874
+ }
875
+
876
+ /*────────────────── multi‑token wildcard ">" — must be **final** ───────────────*/
877
+ if (tokA === ">") {
878
+ if (i !== a.length - 1) return false; // '>' not in last position → invalid
879
+ if (j >= b.length) return false; // must consume at least one token
880
+ starAi = i++; // remember where '>' is
881
+ starAj = ++j; // gobble one token from B
882
+ continue;
883
+ }
884
+ if (tokB === ">") {
885
+ if (j !== b.length - 1) return false; // same rule for patternB
886
+ if (i >= a.length) return false;
887
+ starBi = j++;
888
+ starBj = ++i;
889
+ continue;
890
+ }
891
+
892
+ /*───────────────────────────── back‑track using last '>' ───────────────────────*/
893
+ if (starAi !== -1) { // let patternA's '>' absorb one more token of B
894
+ j = ++starAj;
895
+ continue;
896
+ }
897
+ if (starBi !== -1) { // let patternB's '>' absorb one more token of A
898
+ i = ++starBj;
899
+ continue;
900
+ }
901
+
902
+ /*────────────────────────────────── dead‑end ───────────────────────────────────*/
903
+ return false;
904
+ }
905
+
906
+ return true;
907
+ }
908
+
794
909
  sleep(ms) {
795
910
  return new Promise(resolve => setTimeout(resolve, ms));
796
911
  }
@@ -929,6 +1044,14 @@ ${secret}
929
1044
  return null;
930
1045
  }
931
1046
  }
1047
+
1048
+ testPatternMatcher(){
1049
+ if(process.env.NODE_ENV == "test"){
1050
+ return this.#topicPatternMatcher.bind(this)
1051
+ }else{
1052
+ return null;
1053
+ }
1054
+ }
932
1055
  }
933
1056
 
934
1057
  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
  })