roboto-js 1.6.17 → 1.7.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.
package/src/rbt_api.js CHANGED
@@ -12,6 +12,15 @@ export default class RbtApi {
12
12
 
13
13
  this.websocketClient = null;
14
14
 
15
+ // Object cache for sharing instances across multiple load() calls
16
+ this._objectCache = new Map();
17
+
18
+ // Track pending requests to prevent duplicate loads
19
+ this._pendingLoads = new Map();
20
+
21
+ // Track what we've already logged to reduce console spam
22
+ this._loggedCacheEvents = new Set();
23
+
15
24
  this.axios = axios.create({
16
25
  baseURL: baseUrl,
17
26
  headers: {
@@ -33,10 +42,33 @@ export default class RbtApi {
33
42
  removeItem: (key) => Promise.resolve(localStorage.removeItem(key))
34
43
  };
35
44
  }
45
+
46
+ // Synchronous browser hydration: set auth header and in-memory user immediately
47
+ if (typeof localStorage !== 'undefined') {
48
+ try {
49
+ const token = localStorage.getItem('authtoken');
50
+ if (token) {
51
+ this.authtoken = token;
52
+ this.axios.defaults.headers.common['authtoken'] = token;
53
+ }
54
+ } catch {}
55
+ try {
56
+ const cachedUser = localStorage.getItem('rbtUser');
57
+ if (cachedUser) {
58
+ const parsed = JSON.parse(cachedUser);
59
+ if (parsed && parsed.id) {
60
+ this.currentUser = new RbtUser({ id: parsed.id }, this.axios);
61
+ this.currentUser.setData(parsed);
62
+ }
63
+ }
64
+ } catch {}
65
+ }
36
66
  this.localDb = null;
37
67
  this.iac_session = null;
38
68
  this.appServiceHost = baseUrl;
39
69
  this.requestCache = {};
70
+ this._loadCurrentUserPromise = null;
71
+ this._loadCurrentUserExtendedPromise = null;
40
72
 
41
73
  // Use the storageAdaptor to get the authToken, if available
42
74
  this.initAuthToken(authtoken);
@@ -46,26 +78,93 @@ export default class RbtApi {
46
78
  }
47
79
 
48
80
  getWebSocketClient() {
49
- if (this.websocketClient) return this.websocketClient;
81
+ // Reuse existing WebSocket if it's OPEN or CONNECTING (to prevent race condition)
82
+ if (this.websocketClient &&
83
+ (this.websocketClient.readyState === WebSocket.OPEN ||
84
+ this.websocketClient.readyState === WebSocket.CONNECTING)) {
85
+ return this.websocketClient;
86
+ }
50
87
 
51
88
  const baseUrl = this.axios.defaults.baseURL;
52
89
  const wsProtocol = baseUrl.startsWith('https') ? 'wss://' : 'ws://';
53
90
  const wsUrl = baseUrl.replace(/^https?:\/\//, wsProtocol);
91
+
92
+ console.log('[RbtApi] Creating new WebSocket connection to:', wsUrl + '/realtime');
54
93
  this.websocketClient = new WebSocket(`${wsUrl}/realtime`);
94
+ this._setupWebSocketHandlers(this.websocketClient);
95
+
96
+ return this.websocketClient;
97
+ }
55
98
 
56
- this.websocketClient.onopen = () => {
99
+ _setupWebSocketHandlers(ws) {
100
+ ws.onopen = () => {
57
101
  console.log('[RbtApi] WebSocket connected.');
102
+ this._wsReconnectAttempts = 0;
103
+ this._wsConnected = true;
104
+
105
+ // Re-subscribe to all objects that were previously subscribed
106
+ if (this._wsSubscriptions) {
107
+ for (const objectId of this._wsSubscriptions) {
108
+ ws.send(JSON.stringify({ type: 'subscribe', objectId }));
109
+ }
110
+ }
58
111
  };
59
112
 
60
- this.websocketClient.onclose = () => {
61
- console.warn('[RbtApi] WebSocket closed.');
113
+ ws.onclose = (event) => {
114
+ console.warn('[RbtApi] WebSocket closed:', event.code, event.reason);
115
+ this._wsConnected = false;
116
+
117
+ // Attempt reconnection with exponential backoff
118
+ if (!this._wsManualClose && this._wsReconnectAttempts < 5) {
119
+ const delay = Math.min(1000 * Math.pow(2, this._wsReconnectAttempts), 30000);
120
+ console.log(`[RbtApi] Attempting reconnection in ${delay}ms (attempt ${this._wsReconnectAttempts + 1}/5)`);
121
+
122
+ setTimeout(() => {
123
+ this._wsReconnectAttempts++;
124
+ this.websocketClient = null; // Clear the old connection
125
+ this.getWebSocketClient(); // Create new connection
126
+ }, delay);
127
+ }
62
128
  };
63
129
 
64
- this.websocketClient.onerror = (err) => {
65
- console.error('[RbtApi] WebSocket error:', err.message || err);
130
+ ws.onerror = (err) => {
131
+ console.error('[RbtApi] WebSocket error:', err);
132
+ this._wsConnected = false;
66
133
  };
67
134
 
68
- return this.websocketClient;
135
+ // Handle ping/pong for keep-alive
136
+ ws.addEventListener('ping', () => {
137
+ if (ws.readyState === WebSocket.OPEN) {
138
+ ws.pong();
139
+ }
140
+ });
141
+
142
+ // Initialize connection tracking
143
+ this._wsReconnectAttempts = this._wsReconnectAttempts || 0;
144
+ this._wsConnected = false;
145
+ this._wsManualClose = false;
146
+ this._wsSubscriptions = this._wsSubscriptions || new Set();
147
+ }
148
+
149
+ // Method to track subscriptions for reconnection
150
+ _trackSubscription(objectId) {
151
+ if (!this._wsSubscriptions) this._wsSubscriptions = new Set();
152
+ this._wsSubscriptions.add(objectId);
153
+ }
154
+
155
+ _untrackSubscription(objectId) {
156
+ if (this._wsSubscriptions) {
157
+ this._wsSubscriptions.delete(objectId);
158
+ }
159
+ }
160
+
161
+ // Method to gracefully close WebSocket
162
+ closeWebSocket() {
163
+ if (this.websocketClient) {
164
+ this._wsManualClose = true;
165
+ this.websocketClient.close();
166
+ this.websocketClient = null;
167
+ }
69
168
  }
70
169
 
71
170
  async initAuthToken(authtoken) {
@@ -175,6 +274,9 @@ export default class RbtApi {
175
274
 
176
275
  if(this.localStorageAdaptor){
177
276
  await this.localStorageAdaptor.setItem('authtoken', response.data.authToken);
277
+ if (this.iac_session?.user) {
278
+ await this.localStorageAdaptor.setItem('rbtUser', JSON.stringify(this.iac_session.user));
279
+ }
178
280
  }
179
281
 
180
282
  return response.data;
@@ -256,6 +358,9 @@ export default class RbtApi {
256
358
  async loadCurrentUser(){
257
359
 
258
360
  try {
361
+ if (this._loadCurrentUserPromise) {
362
+ return this._loadCurrentUserPromise;
363
+ }
259
364
 
260
365
  if(this.currentUser){
261
366
  return this.currentUser;
@@ -269,17 +374,24 @@ export default class RbtApi {
269
374
  }
270
375
  else if(this.authtoken){
271
376
 
272
- let response = await this.refreshAuthToken(this.authtoken);
273
- if(!response) return null;
377
+ this._loadCurrentUserPromise = (async () => {
378
+ let response = await this.refreshAuthToken(this.authtoken);
379
+ if(!response) return null;
274
380
 
275
- if(response?.user){
381
+ if(response?.user){
276
382
 
277
- this.currentUser = new RbtUser({ id: response?.user?.id }, this.axios);
278
- this.currentUser.setData(response.user);
279
-
280
- }
281
-
383
+ this.currentUser = new RbtUser({ id: response?.user?.id }, this.axios);
384
+ this.currentUser.setData(response.user);
385
+ if (this.localStorageAdaptor) {
386
+ await this.localStorageAdaptor.setItem('rbtUser', JSON.stringify(response.user));
387
+ }
388
+ }
282
389
  return this.currentUser;
390
+ })();
391
+
392
+ const result = await this._loadCurrentUserPromise;
393
+ this._loadCurrentUserPromise = null;
394
+ return result;
283
395
  //this.currentOrg = new RbtObject(response.organization, this.axios);
284
396
  //this.currentOrg.type = '<@iac.organization>';
285
397
 
@@ -319,12 +431,24 @@ export default class RbtApi {
319
431
 
320
432
  async loadCurrentUserExtended(){
321
433
 
322
- let currentUser = await this.loadCurrentUser();
323
- if(currentUser){
324
- return this.loadUser(currentUser.id);
434
+ if (this._loadCurrentUserExtendedPromise) {
435
+ return this._loadCurrentUserExtendedPromise;
325
436
  }
326
- else{
327
- return null;
437
+
438
+ this._loadCurrentUserExtendedPromise = (async () => {
439
+ let currentUser = await this.loadCurrentUser();
440
+ if(currentUser){
441
+ return this.loadUser(currentUser.id);
442
+ }
443
+ else{
444
+ return null;
445
+ }
446
+ })();
447
+
448
+ try {
449
+ return await this._loadCurrentUserExtendedPromise;
450
+ } finally {
451
+ this._loadCurrentUserExtendedPromise = null;
328
452
  }
329
453
 
330
454
  }
@@ -334,15 +458,23 @@ export default class RbtApi {
334
458
 
335
459
  let params = { id: userId };
336
460
 
337
- try {
338
-
339
- const response = await this.axios.post('/user_service/loadUser', [params]);
340
- let userData = response?.data?.user;
341
-
342
- let User = new RbtUser({ id: userData.id }, this.axios);
343
- User.setData(userData);
344
- return User;
461
+ const cacheKey = `loadUser:${userId}`;
462
+ const now = Date.now();
463
+ const existing = this.requestCache[cacheKey];
464
+ if (existing && (now - existing.time) < 10000) { // 10s TTL
465
+ return existing.val;
466
+ }
345
467
 
468
+ try {
469
+ const p = (async () => {
470
+ const response = await this.axios.post('/user_service/loadUser', [params]);
471
+ let userData = response?.data?.user;
472
+ let User = new RbtUser({ id: userData.id }, this.axios);
473
+ User.setData(userData);
474
+ return User;
475
+ })();
476
+ this.requestCache[cacheKey] = { val: p, time: now };
477
+ return await p;
346
478
  } catch (e) {
347
479
  return this._handleError(e);
348
480
  }
@@ -494,9 +626,10 @@ export default class RbtApi {
494
626
  *
495
627
  * @param {string} type - The type of object to create.
496
628
  * @param {Object} dataHash - The data for the new object.
629
+ * @param {Object} options - Additional options including enableRealtime.
497
630
  * @returns {Promise<RbtObject>} - The newly created object as an RbtObject.
498
631
  */
499
- async create(type, dataHash={}) {
632
+ async create(type, dataHash={}, options={}) {
500
633
  try {
501
634
  const response = await this.axios.post('/object_service/createObject', [type, dataHash]);
502
635
  const record = response.data;
@@ -504,7 +637,11 @@ export default class RbtApi {
504
637
  if(dataHash){
505
638
  record.data = dataHash;
506
639
  }
507
- return new RbtObject(record, this.axios, { isNew: true, websocketClient: this.websocketClient });
640
+ return new RbtObject(record, this.axios, {
641
+ isNew: true,
642
+ websocketClient: this.websocketClient,
643
+ enableRealtime: options.enableRealtime || false
644
+ });
508
645
  } catch (e) {
509
646
  return this._handleError(e);
510
647
  }
@@ -566,7 +703,7 @@ export default class RbtApi {
566
703
  const responsePromise = this.axios.post('/object_service/queryObjects', [mergedParams]);
567
704
 
568
705
  // Cache the promise of processing data, not just the raw response
569
- const processingPromise = responsePromise.then(response => this._processResponseData(response)).catch(e => {
706
+ const processingPromise = responsePromise.then(response => this._processResponseData(response, params)).catch(e => {
570
707
  delete this.requestCache[paramsKey]; // Ensure cache cleanup on failure
571
708
  //console.log('RBTAPI.query ERROR (Processing)', paramsKey, e);
572
709
  return this._handleError(e);
@@ -585,15 +722,32 @@ export default class RbtApi {
585
722
  }
586
723
  }
587
724
 
588
- _processResponseData(response) {
725
+ _processResponseData(response, options = {}) {
589
726
  if (response.data.ok === false) {
590
727
  return this._handleError(response);
591
728
  }
592
729
 
593
730
  if (Array.isArray(response.data.items)) {
594
731
  //console.log('RBTAPI.query RESPONSE PRE', response.data.items);
732
+
733
+ // Ensure WebSocket client is available if realtime is requested
734
+ let websocketClient = this.websocketClient;
735
+ if (options.enableRealtime && !websocketClient) {
736
+ console.log('[AgentProviderSync] Creating WebSocket client for realtime objects');
737
+ websocketClient = this.getWebSocketClient();
738
+ }
739
+
740
+ console.log('[AgentProviderSync] _processResponseData creating objects with:', {
741
+ enableRealtime: options.enableRealtime || false,
742
+ hasWebSocketClient: !!websocketClient,
743
+ itemCount: response.data.items.length
744
+ });
745
+
595
746
  response.data.items = response.data.items.map(record => {
596
- return new RbtObject(record, this.axios, { websocketClient: this.websocketClient });
747
+ return new RbtObject(record, this.axios, {
748
+ websocketClient: websocketClient,
749
+ enableRealtime: options.enableRealtime || false
750
+ });
597
751
  });
598
752
  }
599
753
 
@@ -612,17 +766,149 @@ export default class RbtApi {
612
766
  */
613
767
  async load(type, ids, params={}){
614
768
 
769
+ if(type=='<@iac.user>'){
770
+
771
+ debugger;
772
+ }
773
+
615
774
  try{
616
775
  let mergedParams;
617
776
 
618
777
  if(Array.isArray(ids)){
619
- mergedParams = { ...params, where: `id IN ("${ids.join(`","`)}")` };
620
- return this.query(type, mergedParams);
778
+ // For array requests, check cache for each ID and only load missing ones
779
+ const cachedObjects = [];
780
+ const missingIds = [];
781
+
782
+ for (const id of ids) {
783
+ const cacheKey = `${type}:${id}`;
784
+ const cached = this._objectCache.get(cacheKey);
785
+ if (cached) {
786
+ // Only log cache hits once per object to reduce spam
787
+ const hitLogKey = `hit:${cacheKey}`;
788
+ if (!this._loggedCacheEvents.has(hitLogKey)) {
789
+ console.log('[AgentProviderSync] 🎯 roboto.load cache HIT:', { type, id, hasRealtime: !!cached._realtime, requestedRealtime: !!params.enableRealtime });
790
+ this._loggedCacheEvents.add(hitLogKey);
791
+ }
792
+
793
+ // If realtime is requested but cached object doesn't have it, upgrade it
794
+ if (params.enableRealtime && !cached._realtime) {
795
+ console.log('[AgentProviderSync] 🔄 Upgrading cached object to realtime:', { type, id });
796
+ cached._initRealtime();
797
+ }
798
+
799
+ cachedObjects.push(cached);
800
+ } else {
801
+ missingIds.push(id);
802
+ }
803
+ }
804
+
805
+ // Load missing objects
806
+ let loadedObjects = [];
807
+ if (missingIds.length > 0) {
808
+ // Only log bulk cache miss once
809
+ const bulkMissLogKey = `bulk-miss:${type}:${missingIds.join(',')}`;
810
+ if (!this._loggedCacheEvents.has(bulkMissLogKey)) {
811
+ console.log('[AgentProviderSync] 📦 roboto.load cache MISS, loading:', { type, ids: missingIds });
812
+ this._loggedCacheEvents.add(bulkMissLogKey);
813
+ }
814
+
815
+ mergedParams = { ...params, where: `id IN ("${missingIds.join(`","`)}")` };
816
+ loadedObjects = await this.query(type, mergedParams);
817
+
818
+ // Cache the newly loaded objects
819
+ for (const obj of loadedObjects) {
820
+ const cacheKey = `${type}:${obj.id}`;
821
+ this._objectCache.set(cacheKey, obj);
822
+
823
+ // Only log cache set once per object to reduce spam
824
+ const setLogKey = `set:${cacheKey}`;
825
+ if (!this._loggedCacheEvents.has(setLogKey)) {
826
+ console.log('[AgentProviderSync] 💾 roboto.load cached object:', { type, id: obj.id });
827
+ this._loggedCacheEvents.add(setLogKey);
828
+ }
829
+ }
830
+ }
831
+
832
+ // Return combined results in original order
833
+ const result = [];
834
+ for (const id of ids) {
835
+ const cacheKey = `${type}:${id}`;
836
+ const obj = this._objectCache.get(cacheKey);
837
+ if (obj) {
838
+ // Ensure realtime is enabled if requested
839
+ if (params.enableRealtime && !obj._realtime) {
840
+ obj._initRealtime();
841
+ }
842
+ result.push(obj);
843
+ }
844
+ }
845
+ return result;
621
846
  }
622
847
  else{
623
- mergedParams = { ...params, where: `id="${ids}"` };
624
- let res = await this.query(type, mergedParams);
625
- return res[0];
848
+ // For single object requests, check cache first
849
+ const cacheKey = `${type}:${ids}`;
850
+ const cached = this._objectCache.get(cacheKey);
851
+
852
+ if (cached) {
853
+ // Only log cache hits once per object to reduce spam
854
+ const hitLogKey = `hit:${cacheKey}`;
855
+ if (!this._loggedCacheEvents.has(hitLogKey) || console.log('[AgentProviderSync] 🎯 roboto.load cache HIT:', { type, id: ids, hasRealtime: !!cached._realtime, requestedRealtime: !!params.enableRealtime })) {
856
+ this._loggedCacheEvents.add(hitLogKey);
857
+ }
858
+
859
+ // If realtime is requested but cached object doesn't have it, upgrade it
860
+ if (params.enableRealtime && !cached._realtime) {
861
+ console.log('[AgentProviderSync] 🔄 Upgrading cached object to realtime:', { type, id: ids });
862
+ cached._initRealtime();
863
+ }
864
+
865
+ return cached;
866
+ }
867
+
868
+ // Check if we're already loading this object
869
+ const pendingKey = `${type}:${ids}`;
870
+ if (this._pendingLoads.has(pendingKey)) {
871
+ // Wait for the existing request to complete
872
+ return await this._pendingLoads.get(pendingKey);
873
+ }
874
+
875
+ // Only log cache miss once per object to reduce spam
876
+ const missLogKey = `miss:${cacheKey}`;
877
+ if (!this._loggedCacheEvents.has(missLogKey)) {
878
+ console.log('[AgentProviderSync] 📦 roboto.load cache MISS, loading:', { type, id: ids });
879
+ this._loggedCacheEvents.add(missLogKey);
880
+ }
881
+
882
+ // Create the loading promise and store it to prevent duplicate requests
883
+ const loadPromise = (async () => {
884
+ try {
885
+ mergedParams = { ...params, where: `id="${ids}"` };
886
+ let res = await this.query(type, mergedParams);
887
+ const obj = res[0];
888
+
889
+ if (obj) {
890
+ // Cache the loaded object
891
+ this._objectCache.set(cacheKey, obj);
892
+
893
+ // Only log cache set once per object to reduce spam
894
+ const setLogKey = `set:${cacheKey}`;
895
+ if (!this._loggedCacheEvents.has(setLogKey)) {
896
+ console.log('[AgentProviderSync] 💾 roboto.load cached object:', { type, id: ids });
897
+ this._loggedCacheEvents.add(setLogKey);
898
+ }
899
+ }
900
+
901
+ return obj;
902
+ } finally {
903
+ // Remove from pending loads
904
+ this._pendingLoads.delete(pendingKey);
905
+ }
906
+ })();
907
+
908
+ // Store the promise so other concurrent requests can await it
909
+ this._pendingLoads.set(pendingKey, loadPromise);
910
+
911
+ return await loadPromise;
626
912
  }
627
913
 
628
914
  } catch (e) {
@@ -631,6 +917,73 @@ export default class RbtApi {
631
917
 
632
918
  }
633
919
 
920
+ /**
921
+ * Clears the object cache. Useful for cache invalidation.
922
+ * @param {string} type - Optional object type to clear. If not provided, clears all.
923
+ * @param {string} id - Optional object ID to clear. If not provided with type, clears all objects of that type.
924
+ */
925
+ clearCache(type = null, id = null) {
926
+ if (type && id) {
927
+ // Clear specific object
928
+ const cacheKey = `${type}:${id}`;
929
+ const removed = this._objectCache.delete(cacheKey);
930
+
931
+ // Clear related log tracking
932
+ this._loggedCacheEvents.delete(`hit:${cacheKey}`);
933
+ this._loggedCacheEvents.delete(`miss:${cacheKey}`);
934
+ this._loggedCacheEvents.delete(`set:${cacheKey}`);
935
+
936
+ console.log('[AgentProviderSync] 🗑️ roboto.clearCache specific object:', { type, id, removed });
937
+ } else if (type) {
938
+ // Clear all objects of a specific type
939
+ let removedCount = 0;
940
+ for (const [key] of this._objectCache) {
941
+ if (key.startsWith(`${type}:`)) {
942
+ this._objectCache.delete(key);
943
+ removedCount++;
944
+ }
945
+ }
946
+
947
+ // Clear related log tracking for this type
948
+ for (const logKey of this._loggedCacheEvents) {
949
+ if (logKey.includes(`${type}:`)) {
950
+ this._loggedCacheEvents.delete(logKey);
951
+ }
952
+ }
953
+
954
+ console.log('[AgentProviderSync] 🗑️ roboto.clearCache by type:', { type, removedCount });
955
+ } else {
956
+ // Clear all cached objects
957
+ const size = this._objectCache.size;
958
+ this._objectCache.clear();
959
+ this._loggedCacheEvents.clear();
960
+ this._pendingLoads.clear();
961
+ console.log('[AgentProviderSync] 🗑️ roboto.clearCache all objects:', { clearedCount: size });
962
+ }
963
+ }
964
+
965
+ /**
966
+ * Gets cache status for debugging
967
+ * @returns {Object} Cache status information
968
+ */
969
+ getCacheStatus() {
970
+ const cacheEntries = Array.from(this._objectCache.entries()).map(([key, obj]) => ({
971
+ key,
972
+ id: obj.id,
973
+ type: obj._internalData?.type || 'unknown'
974
+ }));
975
+
976
+ const pendingLoads = Array.from(this._pendingLoads.keys());
977
+
978
+ return {
979
+ cacheSize: this._objectCache.size,
980
+ pendingLoads: pendingLoads.length,
981
+ loggedEvents: this._loggedCacheEvents.size,
982
+ entries: cacheEntries,
983
+ pendingKeys: pendingLoads
984
+ };
985
+ }
986
+
634
987
  /**
635
988
  * Makes a POST request to a specific endpoint to run a task and handle progress updates.
636
989
  *
@@ -84,8 +84,22 @@ export default class RbtMetricsApi extends EventEmitter {
84
84
  this.emit('sent', payload, res.data);
85
85
  return res.data;
86
86
  } catch (err) {
87
- this.emit('error', err, payload);
88
- throw err;
87
+ // Summarize Axios errors for clarity
88
+ let summary = err;
89
+ if (err.isAxiosError) {
90
+ summary = {
91
+ isAxiosError: true,
92
+ message: err.message,
93
+ status: err.response?.status,
94
+ statusText: err.response?.statusText,
95
+ data: err.response?.data,
96
+ url: err.config?.url,
97
+ method: err.config?.method,
98
+ // original: err, // Omit for clean logs, add if you want the raw error for debugging
99
+ };
100
+ }
101
+ this.emit('error', summary, payload);
102
+ throw summary;
89
103
  }
90
104
  }
91
105