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.
@@ -14,6 +14,15 @@ export default class RbtApi {
14
14
  localStorageAdaptor = null
15
15
  }) {
16
16
  this.websocketClient = null;
17
+
18
+ // Object cache for sharing instances across multiple load() calls
19
+ this._objectCache = new Map();
20
+
21
+ // Track pending requests to prevent duplicate loads
22
+ this._pendingLoads = new Map();
23
+
24
+ // Track what we've already logged to reduce console spam
25
+ this._loggedCacheEvents = new Set();
17
26
  this.axios = axios.create({
18
27
  baseURL: baseUrl,
19
28
  headers: {
@@ -32,31 +41,121 @@ export default class RbtApi {
32
41
  removeItem: key => Promise.resolve(localStorage.removeItem(key))
33
42
  };
34
43
  }
44
+
45
+ // Synchronous browser hydration: set auth header and in-memory user immediately
46
+ if (typeof localStorage !== 'undefined') {
47
+ try {
48
+ const token = localStorage.getItem('authtoken');
49
+ if (token) {
50
+ this.authtoken = token;
51
+ this.axios.defaults.headers.common['authtoken'] = token;
52
+ }
53
+ } catch {}
54
+ try {
55
+ const cachedUser = localStorage.getItem('rbtUser');
56
+ if (cachedUser) {
57
+ const parsed = JSON.parse(cachedUser);
58
+ if (parsed && parsed.id) {
59
+ this.currentUser = new RbtUser({
60
+ id: parsed.id
61
+ }, this.axios);
62
+ this.currentUser.setData(parsed);
63
+ }
64
+ }
65
+ } catch {}
66
+ }
35
67
  this.localDb = null;
36
68
  this.iac_session = null;
37
69
  this.appServiceHost = baseUrl;
38
70
  this.requestCache = {};
71
+ this._loadCurrentUserPromise = null;
72
+ this._loadCurrentUserExtendedPromise = null;
39
73
 
40
74
  // Use the storageAdaptor to get the authToken, if available
41
75
  this.initAuthToken(authtoken);
42
76
  this.initApiKey(apikey);
43
77
  }
44
78
  getWebSocketClient() {
45
- if (this.websocketClient) return this.websocketClient;
79
+ // Reuse existing WebSocket if it's OPEN or CONNECTING (to prevent race condition)
80
+ if (this.websocketClient && (this.websocketClient.readyState === WebSocket.OPEN || this.websocketClient.readyState === WebSocket.CONNECTING)) {
81
+ return this.websocketClient;
82
+ }
46
83
  const baseUrl = this.axios.defaults.baseURL;
47
84
  const wsProtocol = baseUrl.startsWith('https') ? 'wss://' : 'ws://';
48
85
  const wsUrl = baseUrl.replace(/^https?:\/\//, wsProtocol);
86
+ console.log('[RbtApi] Creating new WebSocket connection to:', wsUrl + '/realtime');
49
87
  this.websocketClient = new WebSocket(`${wsUrl}/realtime`);
50
- this.websocketClient.onopen = () => {
88
+ this._setupWebSocketHandlers(this.websocketClient);
89
+ return this.websocketClient;
90
+ }
91
+ _setupWebSocketHandlers(ws) {
92
+ ws.onopen = () => {
51
93
  console.log('[RbtApi] WebSocket connected.');
94
+ this._wsReconnectAttempts = 0;
95
+ this._wsConnected = true;
96
+
97
+ // Re-subscribe to all objects that were previously subscribed
98
+ if (this._wsSubscriptions) {
99
+ for (const objectId of this._wsSubscriptions) {
100
+ ws.send(JSON.stringify({
101
+ type: 'subscribe',
102
+ objectId
103
+ }));
104
+ }
105
+ }
52
106
  };
53
- this.websocketClient.onclose = () => {
54
- console.warn('[RbtApi] WebSocket closed.');
107
+ ws.onclose = event => {
108
+ console.warn('[RbtApi] WebSocket closed:', event.code, event.reason);
109
+ this._wsConnected = false;
110
+
111
+ // Attempt reconnection with exponential backoff
112
+ if (!this._wsManualClose && this._wsReconnectAttempts < 5) {
113
+ const delay = Math.min(1000 * Math.pow(2, this._wsReconnectAttempts), 30000);
114
+ console.log(`[RbtApi] Attempting reconnection in ${delay}ms (attempt ${this._wsReconnectAttempts + 1}/5)`);
115
+ setTimeout(() => {
116
+ this._wsReconnectAttempts++;
117
+ this.websocketClient = null; // Clear the old connection
118
+ this.getWebSocketClient(); // Create new connection
119
+ }, delay);
120
+ }
55
121
  };
56
- this.websocketClient.onerror = err => {
57
- console.error('[RbtApi] WebSocket error:', err.message || err);
122
+ ws.onerror = err => {
123
+ console.error('[RbtApi] WebSocket error:', err);
124
+ this._wsConnected = false;
58
125
  };
59
- return this.websocketClient;
126
+
127
+ // Handle ping/pong for keep-alive
128
+ ws.addEventListener('ping', () => {
129
+ if (ws.readyState === WebSocket.OPEN) {
130
+ ws.pong();
131
+ }
132
+ });
133
+
134
+ // Initialize connection tracking
135
+ this._wsReconnectAttempts = this._wsReconnectAttempts || 0;
136
+ this._wsConnected = false;
137
+ this._wsManualClose = false;
138
+ this._wsSubscriptions = this._wsSubscriptions || new Set();
139
+ }
140
+
141
+ // Method to track subscriptions for reconnection
142
+ _trackSubscription(objectId) {
143
+ if (!this._wsSubscriptions) this._wsSubscriptions = new Set();
144
+ this._wsSubscriptions.add(objectId);
145
+ }
146
+ _untrackSubscription(objectId) {
147
+ if (this._wsSubscriptions) {
148
+ this._wsSubscriptions.delete(objectId);
149
+ }
150
+ }
151
+
152
+ // Method to gracefully close WebSocket
153
+ closeWebSocket() {
154
+ if (this.websocketClient) {
155
+ this._wsManualClose = true;
156
+ this.websocketClient.close();
157
+ this.websocketClient = null;
158
+ }
60
159
  }
61
160
  async initAuthToken(authtoken) {
62
161
  if (!authtoken && this.localStorageAdaptor) {
@@ -140,6 +239,9 @@ export default class RbtApi {
140
239
  this.authtoken = response.data.authToken;
141
240
  if (this.localStorageAdaptor) {
142
241
  await this.localStorageAdaptor.setItem('authtoken', response.data.authToken);
242
+ if (this.iac_session?.user) {
243
+ await this.localStorageAdaptor.setItem('rbtUser', JSON.stringify(this.iac_session.user));
244
+ }
143
245
  }
144
246
  return response.data;
145
247
  } catch (e) {
@@ -200,6 +302,9 @@ export default class RbtApi {
200
302
  }
201
303
  async loadCurrentUser() {
202
304
  try {
305
+ if (this._loadCurrentUserPromise) {
306
+ return this._loadCurrentUserPromise;
307
+ }
203
308
  if (this.currentUser) {
204
309
  return this.currentUser;
205
310
  }
@@ -207,15 +312,23 @@ export default class RbtApi {
207
312
  // NOT IMPLEMENTED
208
313
  return null;
209
314
  } else if (this.authtoken) {
210
- let response = await this.refreshAuthToken(this.authtoken);
211
- if (!response) return null;
212
- if (response?.user) {
213
- this.currentUser = new RbtUser({
214
- id: response?.user?.id
215
- }, this.axios);
216
- this.currentUser.setData(response.user);
217
- }
218
- return this.currentUser;
315
+ this._loadCurrentUserPromise = (async () => {
316
+ let response = await this.refreshAuthToken(this.authtoken);
317
+ if (!response) return null;
318
+ if (response?.user) {
319
+ this.currentUser = new RbtUser({
320
+ id: response?.user?.id
321
+ }, this.axios);
322
+ this.currentUser.setData(response.user);
323
+ if (this.localStorageAdaptor) {
324
+ await this.localStorageAdaptor.setItem('rbtUser', JSON.stringify(response.user));
325
+ }
326
+ }
327
+ return this.currentUser;
328
+ })();
329
+ const result = await this._loadCurrentUserPromise;
330
+ this._loadCurrentUserPromise = null;
331
+ return result;
219
332
  //this.currentOrg = new RbtObject(response.organization, this.axios);
220
333
  //this.currentOrg.type = '<@iac.organization>';
221
334
  } else {
@@ -241,25 +354,49 @@ export default class RbtApi {
241
354
  }
242
355
  }
243
356
  async loadCurrentUserExtended() {
244
- let currentUser = await this.loadCurrentUser();
245
- if (currentUser) {
246
- return this.loadUser(currentUser.id);
247
- } else {
248
- return null;
357
+ if (this._loadCurrentUserExtendedPromise) {
358
+ return this._loadCurrentUserExtendedPromise;
359
+ }
360
+ this._loadCurrentUserExtendedPromise = (async () => {
361
+ let currentUser = await this.loadCurrentUser();
362
+ if (currentUser) {
363
+ return this.loadUser(currentUser.id);
364
+ } else {
365
+ return null;
366
+ }
367
+ })();
368
+ try {
369
+ return await this._loadCurrentUserExtendedPromise;
370
+ } finally {
371
+ this._loadCurrentUserExtendedPromise = null;
249
372
  }
250
373
  }
251
374
  async loadUser(userId) {
252
375
  let params = {
253
376
  id: userId
254
377
  };
378
+ const cacheKey = `loadUser:${userId}`;
379
+ const now = Date.now();
380
+ const existing = this.requestCache[cacheKey];
381
+ if (existing && now - existing.time < 10000) {
382
+ // 10s TTL
383
+ return existing.val;
384
+ }
255
385
  try {
256
- const response = await this.axios.post('/user_service/loadUser', [params]);
257
- let userData = response?.data?.user;
258
- let User = new RbtUser({
259
- id: userData.id
260
- }, this.axios);
261
- User.setData(userData);
262
- return User;
386
+ const p = (async () => {
387
+ const response = await this.axios.post('/user_service/loadUser', [params]);
388
+ let userData = response?.data?.user;
389
+ let User = new RbtUser({
390
+ id: userData.id
391
+ }, this.axios);
392
+ User.setData(userData);
393
+ return User;
394
+ })();
395
+ this.requestCache[cacheKey] = {
396
+ val: p,
397
+ time: now
398
+ };
399
+ return await p;
263
400
  } catch (e) {
264
401
  return this._handleError(e);
265
402
  }
@@ -388,9 +525,10 @@ export default class RbtApi {
388
525
  *
389
526
  * @param {string} type - The type of object to create.
390
527
  * @param {Object} dataHash - The data for the new object.
528
+ * @param {Object} options - Additional options including enableRealtime.
391
529
  * @returns {Promise<RbtObject>} - The newly created object as an RbtObject.
392
530
  */
393
- async create(type, dataHash = {}) {
531
+ async create(type, dataHash = {}, options = {}) {
394
532
  try {
395
533
  const response = await this.axios.post('/object_service/createObject', [type, dataHash]);
396
534
  const record = response.data;
@@ -399,7 +537,8 @@ export default class RbtApi {
399
537
  }
400
538
  return new RbtObject(record, this.axios, {
401
539
  isNew: true,
402
- websocketClient: this.websocketClient
540
+ websocketClient: this.websocketClient,
541
+ enableRealtime: options.enableRealtime || false
403
542
  });
404
543
  } catch (e) {
405
544
  return this._handleError(e);
@@ -476,7 +615,7 @@ export default class RbtApi {
476
615
  const responsePromise = this.axios.post('/object_service/queryObjects', [mergedParams]);
477
616
 
478
617
  // Cache the promise of processing data, not just the raw response
479
- const processingPromise = responsePromise.then(response => this._processResponseData(response)).catch(e => {
618
+ const processingPromise = responsePromise.then(response => this._processResponseData(response, params)).catch(e => {
480
619
  delete this.requestCache[paramsKey]; // Ensure cache cleanup on failure
481
620
  //console.log('RBTAPI.query ERROR (Processing)', paramsKey, e);
482
621
  return this._handleError(e);
@@ -496,15 +635,28 @@ export default class RbtApi {
496
635
  return this._handleError(e);
497
636
  }
498
637
  }
499
- _processResponseData(response) {
638
+ _processResponseData(response, options = {}) {
500
639
  if (response.data.ok === false) {
501
640
  return this._handleError(response);
502
641
  }
503
642
  if (Array.isArray(response.data.items)) {
504
643
  //console.log('RBTAPI.query RESPONSE PRE', response.data.items);
644
+
645
+ // Ensure WebSocket client is available if realtime is requested
646
+ let websocketClient = this.websocketClient;
647
+ if (options.enableRealtime && !websocketClient) {
648
+ console.log('[AgentProviderSync] Creating WebSocket client for realtime objects');
649
+ websocketClient = this.getWebSocketClient();
650
+ }
651
+ console.log('[AgentProviderSync] _processResponseData creating objects with:', {
652
+ enableRealtime: options.enableRealtime || false,
653
+ hasWebSocketClient: !!websocketClient,
654
+ itemCount: response.data.items.length
655
+ });
505
656
  response.data.items = response.data.items.map(record => {
506
657
  return new RbtObject(record, this.axios, {
507
- websocketClient: this.websocketClient
658
+ websocketClient: websocketClient,
659
+ enableRealtime: options.enableRealtime || false
508
660
  });
509
661
  });
510
662
  }
@@ -522,27 +674,249 @@ export default class RbtApi {
522
674
  * @returns {Promise<RbtObject|RbtObject[]>} - The loaded object(s) as RbtObject(s).
523
675
  */
524
676
  async load(type, ids, params = {}) {
677
+ if (type == '<@iac.user>') {
678
+ debugger;
679
+ }
525
680
  try {
526
681
  let mergedParams;
527
682
  if (Array.isArray(ids)) {
528
- mergedParams = {
529
- ...params,
530
- where: `id IN ("${ids.join(`","`)}")`
531
- };
532
- return this.query(type, mergedParams);
683
+ // For array requests, check cache for each ID and only load missing ones
684
+ const cachedObjects = [];
685
+ const missingIds = [];
686
+ for (const id of ids) {
687
+ const cacheKey = `${type}:${id}`;
688
+ const cached = this._objectCache.get(cacheKey);
689
+ if (cached) {
690
+ // Only log cache hits once per object to reduce spam
691
+ const hitLogKey = `hit:${cacheKey}`;
692
+ if (!this._loggedCacheEvents.has(hitLogKey)) {
693
+ console.log('[AgentProviderSync] 🎯 roboto.load cache HIT:', {
694
+ type,
695
+ id,
696
+ hasRealtime: !!cached._realtime,
697
+ requestedRealtime: !!params.enableRealtime
698
+ });
699
+ this._loggedCacheEvents.add(hitLogKey);
700
+ }
701
+
702
+ // If realtime is requested but cached object doesn't have it, upgrade it
703
+ if (params.enableRealtime && !cached._realtime) {
704
+ console.log('[AgentProviderSync] 🔄 Upgrading cached object to realtime:', {
705
+ type,
706
+ id
707
+ });
708
+ cached._initRealtime();
709
+ }
710
+ cachedObjects.push(cached);
711
+ } else {
712
+ missingIds.push(id);
713
+ }
714
+ }
715
+
716
+ // Load missing objects
717
+ let loadedObjects = [];
718
+ if (missingIds.length > 0) {
719
+ // Only log bulk cache miss once
720
+ const bulkMissLogKey = `bulk-miss:${type}:${missingIds.join(',')}`;
721
+ if (!this._loggedCacheEvents.has(bulkMissLogKey)) {
722
+ console.log('[AgentProviderSync] 📦 roboto.load cache MISS, loading:', {
723
+ type,
724
+ ids: missingIds
725
+ });
726
+ this._loggedCacheEvents.add(bulkMissLogKey);
727
+ }
728
+ mergedParams = {
729
+ ...params,
730
+ where: `id IN ("${missingIds.join(`","`)}")`
731
+ };
732
+ loadedObjects = await this.query(type, mergedParams);
733
+
734
+ // Cache the newly loaded objects
735
+ for (const obj of loadedObjects) {
736
+ const cacheKey = `${type}:${obj.id}`;
737
+ this._objectCache.set(cacheKey, obj);
738
+
739
+ // Only log cache set once per object to reduce spam
740
+ const setLogKey = `set:${cacheKey}`;
741
+ if (!this._loggedCacheEvents.has(setLogKey)) {
742
+ console.log('[AgentProviderSync] 💾 roboto.load cached object:', {
743
+ type,
744
+ id: obj.id
745
+ });
746
+ this._loggedCacheEvents.add(setLogKey);
747
+ }
748
+ }
749
+ }
750
+
751
+ // Return combined results in original order
752
+ const result = [];
753
+ for (const id of ids) {
754
+ const cacheKey = `${type}:${id}`;
755
+ const obj = this._objectCache.get(cacheKey);
756
+ if (obj) {
757
+ // Ensure realtime is enabled if requested
758
+ if (params.enableRealtime && !obj._realtime) {
759
+ obj._initRealtime();
760
+ }
761
+ result.push(obj);
762
+ }
763
+ }
764
+ return result;
533
765
  } else {
534
- mergedParams = {
535
- ...params,
536
- where: `id="${ids}"`
537
- };
538
- let res = await this.query(type, mergedParams);
539
- return res[0];
766
+ // For single object requests, check cache first
767
+ const cacheKey = `${type}:${ids}`;
768
+ const cached = this._objectCache.get(cacheKey);
769
+ if (cached) {
770
+ // Only log cache hits once per object to reduce spam
771
+ const hitLogKey = `hit:${cacheKey}`;
772
+ if (!this._loggedCacheEvents.has(hitLogKey) || console.log('[AgentProviderSync] 🎯 roboto.load cache HIT:', {
773
+ type,
774
+ id: ids,
775
+ hasRealtime: !!cached._realtime,
776
+ requestedRealtime: !!params.enableRealtime
777
+ })) {
778
+ this._loggedCacheEvents.add(hitLogKey);
779
+ }
780
+
781
+ // If realtime is requested but cached object doesn't have it, upgrade it
782
+ if (params.enableRealtime && !cached._realtime) {
783
+ console.log('[AgentProviderSync] 🔄 Upgrading cached object to realtime:', {
784
+ type,
785
+ id: ids
786
+ });
787
+ cached._initRealtime();
788
+ }
789
+ return cached;
790
+ }
791
+
792
+ // Check if we're already loading this object
793
+ const pendingKey = `${type}:${ids}`;
794
+ if (this._pendingLoads.has(pendingKey)) {
795
+ // Wait for the existing request to complete
796
+ return await this._pendingLoads.get(pendingKey);
797
+ }
798
+
799
+ // Only log cache miss once per object to reduce spam
800
+ const missLogKey = `miss:${cacheKey}`;
801
+ if (!this._loggedCacheEvents.has(missLogKey)) {
802
+ console.log('[AgentProviderSync] 📦 roboto.load cache MISS, loading:', {
803
+ type,
804
+ id: ids
805
+ });
806
+ this._loggedCacheEvents.add(missLogKey);
807
+ }
808
+
809
+ // Create the loading promise and store it to prevent duplicate requests
810
+ const loadPromise = (async () => {
811
+ try {
812
+ mergedParams = {
813
+ ...params,
814
+ where: `id="${ids}"`
815
+ };
816
+ let res = await this.query(type, mergedParams);
817
+ const obj = res[0];
818
+ if (obj) {
819
+ // Cache the loaded object
820
+ this._objectCache.set(cacheKey, obj);
821
+
822
+ // Only log cache set once per object to reduce spam
823
+ const setLogKey = `set:${cacheKey}`;
824
+ if (!this._loggedCacheEvents.has(setLogKey)) {
825
+ console.log('[AgentProviderSync] 💾 roboto.load cached object:', {
826
+ type,
827
+ id: ids
828
+ });
829
+ this._loggedCacheEvents.add(setLogKey);
830
+ }
831
+ }
832
+ return obj;
833
+ } finally {
834
+ // Remove from pending loads
835
+ this._pendingLoads.delete(pendingKey);
836
+ }
837
+ })();
838
+
839
+ // Store the promise so other concurrent requests can await it
840
+ this._pendingLoads.set(pendingKey, loadPromise);
841
+ return await loadPromise;
540
842
  }
541
843
  } catch (e) {
542
844
  return this._handleError(e);
543
845
  }
544
846
  }
545
847
 
848
+ /**
849
+ * Clears the object cache. Useful for cache invalidation.
850
+ * @param {string} type - Optional object type to clear. If not provided, clears all.
851
+ * @param {string} id - Optional object ID to clear. If not provided with type, clears all objects of that type.
852
+ */
853
+ clearCache(type = null, id = null) {
854
+ if (type && id) {
855
+ // Clear specific object
856
+ const cacheKey = `${type}:${id}`;
857
+ const removed = this._objectCache.delete(cacheKey);
858
+
859
+ // Clear related log tracking
860
+ this._loggedCacheEvents.delete(`hit:${cacheKey}`);
861
+ this._loggedCacheEvents.delete(`miss:${cacheKey}`);
862
+ this._loggedCacheEvents.delete(`set:${cacheKey}`);
863
+ console.log('[AgentProviderSync] 🗑️ roboto.clearCache specific object:', {
864
+ type,
865
+ id,
866
+ removed
867
+ });
868
+ } else if (type) {
869
+ // Clear all objects of a specific type
870
+ let removedCount = 0;
871
+ for (const [key] of this._objectCache) {
872
+ if (key.startsWith(`${type}:`)) {
873
+ this._objectCache.delete(key);
874
+ removedCount++;
875
+ }
876
+ }
877
+
878
+ // Clear related log tracking for this type
879
+ for (const logKey of this._loggedCacheEvents) {
880
+ if (logKey.includes(`${type}:`)) {
881
+ this._loggedCacheEvents.delete(logKey);
882
+ }
883
+ }
884
+ console.log('[AgentProviderSync] 🗑️ roboto.clearCache by type:', {
885
+ type,
886
+ removedCount
887
+ });
888
+ } else {
889
+ // Clear all cached objects
890
+ const size = this._objectCache.size;
891
+ this._objectCache.clear();
892
+ this._loggedCacheEvents.clear();
893
+ this._pendingLoads.clear();
894
+ console.log('[AgentProviderSync] 🗑️ roboto.clearCache all objects:', {
895
+ clearedCount: size
896
+ });
897
+ }
898
+ }
899
+
900
+ /**
901
+ * Gets cache status for debugging
902
+ * @returns {Object} Cache status information
903
+ */
904
+ getCacheStatus() {
905
+ const cacheEntries = Array.from(this._objectCache.entries()).map(([key, obj]) => ({
906
+ key,
907
+ id: obj.id,
908
+ type: obj._internalData?.type || 'unknown'
909
+ }));
910
+ const pendingLoads = Array.from(this._pendingLoads.keys());
911
+ return {
912
+ cacheSize: this._objectCache.size,
913
+ pendingLoads: pendingLoads.length,
914
+ loggedEvents: this._loggedCacheEvents.size,
915
+ entries: cacheEntries,
916
+ pendingKeys: pendingLoads
917
+ };
918
+ }
919
+
546
920
  /**
547
921
  * Makes a POST request to a specific endpoint to run a task and handle progress updates.
548
922
  *
@@ -78,8 +78,22 @@ export default class RbtMetricsApi extends EventEmitter {
78
78
  this.emit('sent', payload, res.data);
79
79
  return res.data;
80
80
  } catch (err) {
81
- this.emit('error', err, payload);
82
- throw err;
81
+ // Summarize Axios errors for clarity
82
+ let summary = err;
83
+ if (err.isAxiosError) {
84
+ summary = {
85
+ isAxiosError: true,
86
+ message: err.message,
87
+ status: err.response?.status,
88
+ statusText: err.response?.statusText,
89
+ data: err.response?.data,
90
+ url: err.config?.url,
91
+ method: err.config?.method
92
+ // original: err, // Omit for clean logs, add if you want the raw error for debugging
93
+ };
94
+ }
95
+ this.emit('error', summary, payload);
96
+ throw summary;
83
97
  }
84
98
  }
85
99