@capsitech/react-utilities 0.1.14 → 0.1.15

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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * CrossTabApiCoordinator - Ensures API calls execute only once across multiple browser tabs
3
+ * Uses Web Locks API for coordination and BroadcastChannel for result sharing
4
+ */
5
+ /**
6
+ * Options for CrossTabApiCoordinator:
7
+ * - `cacheTTL`: Time in milliseconds to cache results to prevent duplicate calls (default: 1000ms)
8
+ * - `timeout`: Time in milliseconds before a request times out (default: 30000ms)
9
+ */
10
+ interface CrossTabApiCoordinatorOptions {
11
+ cacheTTL?: number;
12
+ timeout?: number;
13
+ }
14
+ /**
15
+ * The `CrossTabApiCoordinator` uses modern browser APIs to coordinate API calls:
16
+ * - **Web Locks API**: Ensures only one tab executes the call
17
+ * - **BroadcastChannel API**: Shares results with other tabs
18
+ * - **Built-in caching**: Prevents duplicate calls within a time window
19
+ * - **Fallback**: Automatically falls back to direct API calls if browser APIs aren't supported.
20
+ */
21
+ declare class CrossTabApiCoordinator {
22
+ private channels;
23
+ private pendingRequests;
24
+ private cache;
25
+ private readonly CACHE_TTL;
26
+ private readonly REQUEST_TIMEOUT;
27
+ private readonly MAX_CACHE_SIZE;
28
+ private cleanupInterval;
29
+ private isSupported;
30
+ constructor();
31
+ /**
32
+ * Check if required browser APIs are supported
33
+ */
34
+ private checkBrowserSupport;
35
+ /**
36
+ * Start periodic cache cleanup to prevent memory leaks
37
+ */
38
+ private startPeriodicCleanup;
39
+ /**
40
+ * Remove stale cache entries
41
+ */
42
+ private cleanupStaleCache;
43
+ /**
44
+ * Remove stale pending requests
45
+ */
46
+ private cleanupStalePendingRequests;
47
+ /**
48
+ * Validate broadcast message structure
49
+ */
50
+ private isValidMessage;
51
+ /**
52
+ * Serialize data for broadcast (handle circular references and non-cloneable objects)
53
+ */
54
+ private serializeData;
55
+ /**
56
+ * Execute an API call coordinated across tabs
57
+ * @param key Unique identifier for this API call (e.g., 'phoneCall-get-123')
58
+ * @param apiCall Function that returns a Promise with the API call
59
+ * @param options Configuration options
60
+ */
61
+ execute<T>(key: string, apiCall: () => Promise<T>, options?: CrossTabApiCoordinatorOptions): Promise<T>;
62
+ /**
63
+ * Handle messages from other tabs
64
+ */
65
+ private handleMessage;
66
+ /**
67
+ * Resolve all pending requests for a key
68
+ */
69
+ private resolvePendingRequests;
70
+ /**
71
+ * Reject all pending requests for a key
72
+ */
73
+ private rejectPendingRequests;
74
+ /**
75
+ * Cleanup a specific request
76
+ */
77
+ private cleanupRequest;
78
+ /**
79
+ * Cleanup broadcast channel
80
+ */
81
+ private cleanupChannel;
82
+ /**
83
+ * Clear cache for a specific key or all keys
84
+ */
85
+ clearCache(key?: string): void;
86
+ /**
87
+ * Cleanup all resources
88
+ */
89
+ cleanup(): void;
90
+ }
91
+ export declare const CrossTabApi: CrossTabApiCoordinator;
92
+ /**
93
+ * Helper function to wrap API calls with cross-tab coordination
94
+ * @example
95
+ * const result = await CoordinateCrossTabApiCall(
96
+ * 'phoneCall-get-' + callId,
97
+ * () => PhoneCallService.get(callId, true)
98
+ * );
99
+ *
100
+ * @param key (string): Unique identifier for this API call. Same key = shared execution.
101
+ * @param apiCall (() => Promise<T>): Function that returns the API call promise.
102
+ * @param options (optional)
103
+ */
104
+ export declare function CoordinateCrossTabApiCall<T>(key: string, apiCall: () => Promise<T>, options?: CrossTabApiCoordinatorOptions): Promise<T>;
105
+ export {};
@@ -0,0 +1,407 @@
1
+ /**
2
+ * CrossTabApiCoordinator - Ensures API calls execute only once across multiple browser tabs
3
+ * Uses Web Locks API for coordination and BroadcastChannel for result sharing
4
+ */
5
+ /**
6
+ * The `CrossTabApiCoordinator` uses modern browser APIs to coordinate API calls:
7
+ * - **Web Locks API**: Ensures only one tab executes the call
8
+ * - **BroadcastChannel API**: Shares results with other tabs
9
+ * - **Built-in caching**: Prevents duplicate calls within a time window
10
+ * - **Fallback**: Automatically falls back to direct API calls if browser APIs aren't supported.
11
+ */
12
+ class CrossTabApiCoordinator {
13
+ channels = new Map();
14
+ pendingRequests = new Map();
15
+ cache = new Map();
16
+ CACHE_TTL = 1000; // 1 second cache to avoid duplicate calls
17
+ REQUEST_TIMEOUT = 30000; // 30 seconds timeout
18
+ MAX_CACHE_SIZE = 100; // Prevent memory leaks
19
+ cleanupInterval = null;
20
+ isSupported = null;
21
+ constructor() {
22
+ // Start periodic cleanup
23
+ this.startPeriodicCleanup();
24
+ // Cleanup on page unload
25
+ if (typeof window !== 'undefined') {
26
+ window.addEventListener('beforeunload', () => this.cleanup());
27
+ }
28
+ }
29
+ /**
30
+ * Check if required browser APIs are supported
31
+ */
32
+ checkBrowserSupport() {
33
+ if (this.isSupported !== null) {
34
+ return this.isSupported;
35
+ }
36
+ const hasLocks = typeof navigator !== 'undefined' && 'locks' in navigator;
37
+ const hasBroadcastChannel = typeof BroadcastChannel !== 'undefined';
38
+ this.isSupported = hasLocks && hasBroadcastChannel;
39
+ if (!this.isSupported) {
40
+ console.warn('CrossTabApiCoordinator: Required APIs not supported. Missing:', !hasLocks ? 'Web Locks API' : '', !hasBroadcastChannel ? 'BroadcastChannel API' : '');
41
+ }
42
+ return this.isSupported;
43
+ }
44
+ /**
45
+ * Start periodic cache cleanup to prevent memory leaks
46
+ */
47
+ startPeriodicCleanup() {
48
+ if (this.cleanupInterval)
49
+ return;
50
+ this.cleanupInterval = setInterval(() => {
51
+ this.cleanupStaleCache();
52
+ this.cleanupStalePendingRequests();
53
+ }, 60000); // Run every minute
54
+ }
55
+ /**
56
+ * Remove stale cache entries
57
+ */
58
+ cleanupStaleCache() {
59
+ const now = Date.now();
60
+ const maxAge = 300000; // 5 minutes
61
+ const entries = Array.from(this.cache.entries());
62
+ for (const [key, value] of entries) {
63
+ if (now - value.timestamp > maxAge) {
64
+ this.cache.delete(key);
65
+ }
66
+ }
67
+ // If cache is still too large, remove oldest entries
68
+ if (this.cache.size > this.MAX_CACHE_SIZE) {
69
+ const entries = Array.from(this.cache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp);
70
+ const toRemove = entries.slice(0, entries.length - this.MAX_CACHE_SIZE);
71
+ toRemove.forEach(([key]) => this.cache.delete(key));
72
+ }
73
+ }
74
+ /**
75
+ * Remove stale pending requests
76
+ */
77
+ cleanupStalePendingRequests() {
78
+ const now = Date.now();
79
+ const maxAge = this.REQUEST_TIMEOUT * 2;
80
+ const entries = Array.from(this.pendingRequests.entries());
81
+ for (const [key, requests] of entries) {
82
+ const validRequests = requests.filter((req) => {
83
+ if (now - req.timestamp > maxAge) {
84
+ // Clear timeout and reject
85
+ clearTimeout(req.timeoutId);
86
+ req.reject(new Error('Request timed out and cleaned up'));
87
+ return false;
88
+ }
89
+ return true;
90
+ });
91
+ if (validRequests.length === 0) {
92
+ this.pendingRequests.delete(key);
93
+ this.cleanupChannel(key);
94
+ }
95
+ else if (validRequests.length !== requests.length) {
96
+ this.pendingRequests.set(key, validRequests);
97
+ }
98
+ }
99
+ }
100
+ /**
101
+ * Validate broadcast message structure
102
+ */
103
+ isValidMessage(data) {
104
+ return (data && typeof data === 'object' && typeof data.type === 'string' && (data.type === 'success' || data.type === 'error') && typeof data.key === 'string' && typeof data.timestamp === 'number');
105
+ }
106
+ /**
107
+ * Serialize data for broadcast (handle circular references and non-cloneable objects)
108
+ */
109
+ serializeData(data) {
110
+ try {
111
+ // Test if data is cloneable by attempting to structure clone
112
+ structuredClone(data);
113
+ return data;
114
+ }
115
+ catch (error) {
116
+ // Fallback to JSON serialization for non-cloneable data
117
+ console.warn('Data contains non-cloneable objects, using JSON serialization');
118
+ try {
119
+ return JSON.parse(JSON.stringify(data));
120
+ }
121
+ catch (jsonError) {
122
+ console.error('Failed to serialize data:', jsonError);
123
+ return null;
124
+ }
125
+ }
126
+ }
127
+ /**
128
+ * Execute an API call coordinated across tabs
129
+ * @param key Unique identifier for this API call (e.g., 'phoneCall-get-123')
130
+ * @param apiCall Function that returns a Promise with the API call
131
+ * @param options Configuration options
132
+ */
133
+ async execute(key, apiCall, options = {}) {
134
+ const cacheTTL = options.cacheTTL ?? this.CACHE_TTL;
135
+ const timeout = options.timeout ?? this.REQUEST_TIMEOUT;
136
+ // Validate key
137
+ if (!key || typeof key !== 'string') {
138
+ throw new Error('Invalid key: must be a non-empty string');
139
+ }
140
+ // Check cache first
141
+ const cached = this.cache.get(key);
142
+ if (cached && Date.now() - cached.timestamp < cacheTTL) {
143
+ return cached.data;
144
+ }
145
+ // Check if required APIs are supported - fallback to direct execution
146
+ if (!this.checkBrowserSupport()) {
147
+ console.warn(`CrossTabApiCoordinator: Browser APIs not supported, executing API call directly for key: ${key}`);
148
+ return apiCall();
149
+ }
150
+ return new Promise((resolve, reject) => {
151
+ const requestId = `${key}-${Date.now()}-${Math.random()}`;
152
+ const channelName = `api-coordinator-${key}`;
153
+ let isResolved = false;
154
+ // Create timeout with cleanup
155
+ const timeoutId = setTimeout(() => {
156
+ if (!isResolved) {
157
+ isResolved = true;
158
+ this.cleanupRequest(key, requestId);
159
+ reject(new Error(`API call timeout for key: ${key}`));
160
+ }
161
+ }, timeout);
162
+ // Store this request
163
+ if (!this.pendingRequests.has(key)) {
164
+ this.pendingRequests.set(key, []);
165
+ }
166
+ this.pendingRequests.get(key).push({
167
+ resolve: (value) => {
168
+ if (!isResolved) {
169
+ isResolved = true;
170
+ clearTimeout(timeoutId);
171
+ resolve(value);
172
+ }
173
+ },
174
+ reject: (error) => {
175
+ if (!isResolved) {
176
+ isResolved = true;
177
+ clearTimeout(timeoutId);
178
+ reject(error);
179
+ }
180
+ },
181
+ timestamp: Date.now(),
182
+ timeoutId,
183
+ });
184
+ // Get or create broadcast channel for this key
185
+ // CRITICAL: Must be created BEFORE attempting lock to avoid missing broadcasts
186
+ let channel;
187
+ try {
188
+ if (!this.channels.has(key)) {
189
+ channel = new BroadcastChannel(channelName);
190
+ channel.onmessage = (event) => this.handleMessage(key, event);
191
+ channel.onmessageerror = (event) => {
192
+ console.error('BroadcastChannel message error:', event);
193
+ };
194
+ this.channels.set(key, channel);
195
+ }
196
+ else {
197
+ channel = this.channels.get(key);
198
+ }
199
+ }
200
+ catch (error) {
201
+ console.error('Failed to create BroadcastChannel:', error);
202
+ console.warn('Falling back to direct API execution without coordination');
203
+ clearTimeout(timeoutId);
204
+ this.cleanupRequest(key, requestId);
205
+ // Fallback to direct execution if BroadcastChannel fails
206
+ return apiCall().then(resolve).catch(reject);
207
+ }
208
+ // Try to acquire lock with ifAvailable - only first tab gets it immediately
209
+ // Other tabs will fail to get the lock and wait for broadcast
210
+ navigator.locks
211
+ .request(`api-${key}`, { mode: 'exclusive', ifAvailable: true }, async (lock) => {
212
+ if (!lock) {
213
+ // This tab didn't get the lock - another tab is executing
214
+ // Just wait for the broadcast message (already set up via pendingRequests)
215
+ return null; // Return control immediately, wait for broadcast
216
+ }
217
+ // This tab got the lock - execute the API call
218
+ try {
219
+ const result = await apiCall();
220
+ // Serialize data to ensure it can be broadcast
221
+ const serializedResult = this.serializeData(result);
222
+ // Cache the result
223
+ this.cache.set(key, { data: result, timestamp: Date.now() });
224
+ // Broadcast result to all tabs (including this one)
225
+ try {
226
+ const message = {
227
+ type: 'success',
228
+ data: serializedResult,
229
+ key,
230
+ timestamp: Date.now(),
231
+ };
232
+ channel.postMessage(message);
233
+ }
234
+ catch (broadcastError) {
235
+ console.error('Failed to broadcast success:', broadcastError);
236
+ // Still resolve local requests even if broadcast fails
237
+ }
238
+ // Resolve all pending requests in this tab
239
+ this.resolvePendingRequests(key, result);
240
+ return result;
241
+ }
242
+ catch (error) {
243
+ // Broadcast error to all tabs
244
+ try {
245
+ const message = {
246
+ type: 'error',
247
+ error: error instanceof Error ? error.message : String(error),
248
+ key,
249
+ timestamp: Date.now(),
250
+ };
251
+ channel.postMessage(message);
252
+ }
253
+ catch (broadcastError) {
254
+ console.error('Failed to broadcast error:', broadcastError);
255
+ }
256
+ // Reject all pending requests in this tab
257
+ this.rejectPendingRequests(key, error);
258
+ throw error;
259
+ }
260
+ finally {
261
+ // Clean up channel after a delay
262
+ setTimeout(() => this.cleanupChannel(key), 5000);
263
+ }
264
+ })
265
+ .catch((error) => {
266
+ console.error('Lock request failed:', error);
267
+ console.warn('Falling back to direct API execution without coordination');
268
+ clearTimeout(timeoutId);
269
+ this.cleanupRequest(key, requestId);
270
+ // If lock mechanism fails, fall back to direct execution
271
+ if (!isResolved) {
272
+ isResolved = true;
273
+ apiCall().then(resolve).catch(reject);
274
+ }
275
+ });
276
+ });
277
+ }
278
+ /**
279
+ * Handle messages from other tabs
280
+ */
281
+ handleMessage(key, event) {
282
+ // Validate message structure
283
+ if (!this.isValidMessage(event.data)) {
284
+ console.warn('Received invalid message:', event.data);
285
+ return;
286
+ }
287
+ const { type, data, error, timestamp } = event.data;
288
+ // Ignore stale messages (older than 1 minute)
289
+ if (Date.now() - timestamp > 60000) {
290
+ return;
291
+ }
292
+ if (type === 'success') {
293
+ // Cache the result
294
+ this.cache.set(key, { data, timestamp: Date.now() });
295
+ // Resolve all pending requests
296
+ this.resolvePendingRequests(key, data);
297
+ }
298
+ else if (type === 'error') {
299
+ // Reject all pending requests
300
+ this.rejectPendingRequests(key, new Error(error || 'Unknown error'));
301
+ }
302
+ }
303
+ /**
304
+ * Resolve all pending requests for a key
305
+ */
306
+ resolvePendingRequests(key, data) {
307
+ const requests = this.pendingRequests.get(key) || [];
308
+ requests.forEach((req) => req.resolve(data));
309
+ this.pendingRequests.delete(key);
310
+ }
311
+ /**
312
+ * Reject all pending requests for a key
313
+ */
314
+ rejectPendingRequests(key, error) {
315
+ const requests = this.pendingRequests.get(key) || [];
316
+ requests.forEach((req) => req.reject(error));
317
+ this.pendingRequests.delete(key);
318
+ }
319
+ /**
320
+ * Cleanup a specific request
321
+ */
322
+ cleanupRequest(key, requestId) {
323
+ const requests = this.pendingRequests.get(key);
324
+ if (requests) {
325
+ const filtered = requests.filter((req) => req.timestamp.toString() !== requestId.split('-')[1]);
326
+ if (filtered.length > 0) {
327
+ this.pendingRequests.set(key, filtered);
328
+ }
329
+ else {
330
+ this.pendingRequests.delete(key);
331
+ }
332
+ }
333
+ }
334
+ /**
335
+ * Cleanup broadcast channel
336
+ */
337
+ cleanupChannel(key) {
338
+ const channel = this.channels.get(key);
339
+ if (channel && (!this.pendingRequests.has(key) || this.pendingRequests.get(key).length === 0)) {
340
+ channel.close();
341
+ this.channels.delete(key);
342
+ }
343
+ }
344
+ /**
345
+ * Clear cache for a specific key or all keys
346
+ */
347
+ clearCache(key) {
348
+ if (key) {
349
+ this.cache.delete(key);
350
+ }
351
+ else {
352
+ this.cache.clear();
353
+ }
354
+ }
355
+ /**
356
+ * Cleanup all resources
357
+ */
358
+ cleanup() {
359
+ // Clear periodic cleanup
360
+ if (this.cleanupInterval) {
361
+ clearInterval(this.cleanupInterval);
362
+ this.cleanupInterval = null;
363
+ }
364
+ // Clear all timeouts in pending requests
365
+ this.pendingRequests.forEach((requests) => {
366
+ requests.forEach((req) => {
367
+ clearTimeout(req.timeoutId);
368
+ // Reject pending requests on cleanup
369
+ try {
370
+ req.reject(new Error('Coordinator cleanup'));
371
+ }
372
+ catch (e) {
373
+ // Ignore if already resolved
374
+ }
375
+ });
376
+ });
377
+ // Close all channels
378
+ this.channels.forEach((channel) => {
379
+ try {
380
+ channel.close();
381
+ }
382
+ catch (e) {
383
+ console.error('Error closing channel:', e);
384
+ }
385
+ });
386
+ this.channels.clear();
387
+ this.pendingRequests.clear();
388
+ this.cache.clear();
389
+ }
390
+ }
391
+ // Export singleton instance
392
+ export const CrossTabApi = new CrossTabApiCoordinator();
393
+ /**
394
+ * Helper function to wrap API calls with cross-tab coordination
395
+ * @example
396
+ * const result = await CoordinateCrossTabApiCall(
397
+ * 'phoneCall-get-' + callId,
398
+ * () => PhoneCallService.get(callId, true)
399
+ * );
400
+ *
401
+ * @param key (string): Unique identifier for this API call. Same key = shared execution.
402
+ * @param apiCall (() => Promise<T>): Function that returns the API call promise.
403
+ * @param options (optional)
404
+ */
405
+ export async function CoordinateCrossTabApiCall(key, apiCall, options) {
406
+ return CrossTabApi.execute(key, apiCall, options);
407
+ }
@@ -1,6 +1,7 @@
1
1
  export * from './ApiUtility.axios';
2
2
  export * from './BrowserInfo';
3
3
  export * from './Countries';
4
+ export * from './CrossTabApiCoordinator';
4
5
  export * from './CustomEventEmitter';
5
6
  export * from './dayjs';
6
7
  export * from './FastCompare';
@@ -1,6 +1,7 @@
1
1
  export * from './ApiUtility.axios';
2
2
  export * from './BrowserInfo';
3
3
  export * from './Countries';
4
+ export * from './CrossTabApiCoordinator';
4
5
  export * from './CustomEventEmitter';
5
6
  export * from './dayjs';
6
7
  export * from './FastCompare';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capsitech/react-utilities",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "A set of javascript utility methods",
5
5
  "main": "lib/index.js",
6
6
  "jsnext:main": "lib/index.js",