react-broadcast-sync 1.0.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.
@@ -0,0 +1,504 @@
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { useBroadcastChannel } from '../hooks/useBroadcastChannel';
3
+
4
+ let mockChannels: any[] = [];
5
+
6
+ class MockBroadcastChannel {
7
+ name: string;
8
+ onmessage: ((event: MessageEvent) => void) | null = null;
9
+ postMessage = jest.fn();
10
+ close = jest.fn();
11
+ addEventListener = jest.fn((event: string, callback: (event: MessageEvent) => void) => {
12
+ if (event === 'message') {
13
+ this.onmessage = callback;
14
+ }
15
+ });
16
+ removeEventListener = jest.fn();
17
+
18
+ constructor(name: string) {
19
+ this.name = name;
20
+ mockChannels.push(this);
21
+ }
22
+
23
+ simulateMessage(data: any) {
24
+ if (this.onmessage) {
25
+ this.onmessage({ data } as MessageEvent);
26
+ }
27
+ }
28
+ }
29
+
30
+ const waitForChannel = async () => {
31
+ return new Promise<void>((resolve) => {
32
+ const checkChannel = () => {
33
+ if (mockChannels.length > 0) {
34
+ resolve();
35
+ } else {
36
+ setTimeout(checkChannel, 0);
37
+ }
38
+ };
39
+ checkChannel();
40
+ });
41
+ };
42
+
43
+ beforeAll(() => {
44
+ global.BroadcastChannel = MockBroadcastChannel as any;
45
+ });
46
+
47
+ beforeEach(() => {
48
+ mockChannels = [];
49
+ global.BroadcastChannel = MockBroadcastChannel as any;
50
+ });
51
+
52
+ afterEach(() => {
53
+ jest.clearAllMocks();
54
+ global.BroadcastChannel = MockBroadcastChannel as any;
55
+ });
56
+
57
+ afterAll(() => {
58
+ delete (global as any).BroadcastChannel;
59
+ });
60
+
61
+ describe('useBroadcastChannel', () => {
62
+ it('posts and receives message', async () => {
63
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
64
+ await waitForChannel();
65
+ const channel = mockChannels[0];
66
+
67
+ act(() => {
68
+ result.current.postMessage('greeting', { text: 'hi' });
69
+ });
70
+
71
+ const incomingMessage = {
72
+ id: 'external-1',
73
+ type: 'greeting',
74
+ message: { text: 'hi' },
75
+ source: 'another-tab',
76
+ timestamp: Date.now()
77
+ };
78
+
79
+ act(() => {
80
+ channel.simulateMessage(incomingMessage);
81
+ });
82
+
83
+ expect(result.current.messages.length).toBe(1);
84
+ expect(result.current.messages[0].message).toEqual({ text: 'hi' });
85
+ });
86
+
87
+ it('ignores self messages', async () => {
88
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
89
+ await waitForChannel();
90
+ const channel = mockChannels[0];
91
+
92
+ act(() => {
93
+ result.current.postMessage('test', 'msg');
94
+ const selfMsg = result.current.sentMessages[0];
95
+ channel.simulateMessage(selfMsg);
96
+ });
97
+
98
+ expect(result.current.messages).toEqual([]);
99
+ });
100
+
101
+ it('clears a specific message', async () => {
102
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
103
+ await waitForChannel();
104
+ const channel = mockChannels[0];
105
+
106
+ const msg = {
107
+ id: '123',
108
+ type: 'greeting',
109
+ message: 'hi',
110
+ source: 'external',
111
+ timestamp: Date.now()
112
+ };
113
+
114
+ act(() => {
115
+ channel.simulateMessage(msg);
116
+ });
117
+
118
+ expect(result.current.messages.length).toBe(1);
119
+
120
+ act(() => {
121
+ result.current.clearMessage('123');
122
+ });
123
+
124
+ expect(result.current.messages.length).toBe(0);
125
+ });
126
+
127
+ it('clears all messages', async () => {
128
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
129
+ await waitForChannel();
130
+ const channel = mockChannels[0];
131
+
132
+ act(() => {
133
+ channel.simulateMessage({
134
+ id: '1',
135
+ type: 'a',
136
+ message: 'a',
137
+ source: 'x',
138
+ timestamp: Date.now()
139
+ });
140
+ channel.simulateMessage({
141
+ id: '2',
142
+ type: 'b',
143
+ message: 'b',
144
+ source: 'x',
145
+ timestamp: Date.now()
146
+ });
147
+ });
148
+
149
+ expect(result.current.messages.length).toBe(2);
150
+
151
+ act(() => {
152
+ result.current.clearAllMessages();
153
+ });
154
+
155
+ expect(result.current.messages.length).toBe(0);
156
+ });
157
+
158
+ it('clears sent message', async () => {
159
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
160
+ await waitForChannel();
161
+
162
+ act(() => {
163
+ result.current.postMessage('test-type', { key: 'value' });
164
+ });
165
+
166
+ const sentId = result.current.sentMessages[0]?.id;
167
+ expect(result.current.sentMessages.length).toBe(1);
168
+
169
+ act(() => {
170
+ result.current.clearSentMessage(sentId);
171
+ });
172
+
173
+ expect(result.current.sentMessages.length).toBe(0);
174
+ });
175
+
176
+ it('handles channel not available error', () => {
177
+ jest.useFakeTimers();
178
+
179
+ const original = global.BroadcastChannel;
180
+ // @ts-ignore
181
+ delete global.BroadcastChannel;
182
+
183
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
184
+
185
+ act(() => {
186
+ result.current.postMessage('test', { a: 1 });
187
+ });
188
+
189
+ expect(result.current.error).toBe(
190
+ 'BroadcastChannel is not supported in this browser. Please check browser compatibility.'
191
+ );
192
+
193
+ global.BroadcastChannel = original;
194
+ jest.useRealTimers();
195
+ });
196
+
197
+ it('sets error when BroadcastChannel is not supported', () => {
198
+ const original = global.BroadcastChannel;
199
+ delete (global as any).BroadcastChannel;
200
+
201
+ const { result } = renderHook(() => useBroadcastChannel('unsupported-channel'));
202
+
203
+ expect(result.current.error).toBe('BroadcastChannel is not supported in this browser. Please check browser compatibility.');
204
+ global.BroadcastChannel = original;
205
+ });
206
+
207
+ it('handles invalid messages', async () => {
208
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
209
+ await waitForChannel();
210
+ const channel = mockChannels[0];
211
+
212
+ act(() => {
213
+ channel.simulateMessage({ invalid: 'message' });
214
+ });
215
+
216
+ expect(result.current.messages.length).toBe(0);
217
+ });
218
+
219
+ it('handles expired messages', async () => {
220
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
221
+ await waitForChannel();
222
+ const channel = mockChannels[0];
223
+
224
+ const expiredMessage = {
225
+ id: 'expired-1',
226
+ type: 'test',
227
+ message: 'expired',
228
+ source: 'another-tab',
229
+ timestamp: Date.now() - 1000000,
230
+ expirationDate: Date.now() - 1000000
231
+ };
232
+
233
+ act(() => {
234
+ channel.simulateMessage(expiredMessage);
235
+ });
236
+
237
+ expect(result.current.messages.length).toBe(0);
238
+ });
239
+
240
+ it('handles duplicate messages within TTL', async () => {
241
+ const { result } = renderHook(() => useBroadcastChannel('test-channel', { deduplicationTTL: 5000 }));
242
+ await waitForChannel();
243
+ const channel = mockChannels[0];
244
+
245
+ const message = {
246
+ id: 'duplicate-1',
247
+ type: 'test',
248
+ message: 'duplicate',
249
+ source: 'another-tab',
250
+ timestamp: Date.now()
251
+ };
252
+
253
+ act(() => {
254
+ channel.simulateMessage(message);
255
+ channel.simulateMessage(message);
256
+ });
257
+
258
+ expect(result.current.messages.length).toBe(1);
259
+ });
260
+
261
+ it('handles internal clear message from other source', async () => {
262
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
263
+ await waitForChannel();
264
+ const channel = mockChannels[0];
265
+
266
+ act(() => {
267
+ channel.simulateMessage({
268
+ id: 'to-clear',
269
+ type: 'test',
270
+ message: 'will be cleared',
271
+ source: 'another-tab',
272
+ timestamp: Date.now()
273
+ });
274
+ });
275
+
276
+ expect(result.current.messages.length).toBe(1);
277
+
278
+ act(() => {
279
+ channel.simulateMessage({
280
+ id: 'to-clear',
281
+ type: '__INTERNAL__:CLEAR_MESSAGE:' + btoa('react-broadcast-sync:CLEAR_MESSAGE:test-channel-'),
282
+ source: 'another-tab',
283
+ timestamp: Date.now()
284
+ });
285
+ });
286
+
287
+ expect(result.current.messages.length).toBe(0);
288
+ });
289
+
290
+ it('handles internal clear all messages from other source', async () => {
291
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
292
+ await waitForChannel();
293
+ const channel = mockChannels[0];
294
+
295
+ act(() => {
296
+ channel.simulateMessage({
297
+ id: 'msg1',
298
+ type: 'test1',
299
+ message: 'first',
300
+ source: 'another-tab',
301
+ timestamp: Date.now()
302
+ });
303
+ channel.simulateMessage({
304
+ id: 'msg2',
305
+ type: 'test2',
306
+ message: 'second',
307
+ source: 'another-tab',
308
+ timestamp: Date.now()
309
+ });
310
+ });
311
+
312
+ expect(result.current.messages.length).toBe(2);
313
+
314
+ act(() => {
315
+ channel.simulateMessage({
316
+ id: '__INTERNAL__:CLEAR_ALL_MESSAGES:' + btoa('react-broadcast-sync:CLEAR_ALL_MESSAGES:test-channel-'),
317
+ type: '__INTERNAL__:CLEAR_ALL_MESSAGES:' + btoa('react-broadcast-sync:CLEAR_ALL_MESSAGES:test-channel-'),
318
+ source: 'another-tab',
319
+ timestamp: Date.now()
320
+ });
321
+ });
322
+
323
+ expect(result.current.messages.length).toBe(0);
324
+ });
325
+
326
+ it('performs cleanup of expired messages', async () => {
327
+ jest.useFakeTimers();
328
+ const { result } = renderHook(() => useBroadcastChannel('test-channel', { cleaningInterval: 1000 }));
329
+ await waitForChannel();
330
+ const channel = mockChannels[0];
331
+
332
+ act(() => {
333
+ channel.simulateMessage({
334
+ id: 'expired-2',
335
+ type: 'test',
336
+ message: 'expired',
337
+ source: 'another-tab',
338
+ timestamp: Date.now() - 1000000,
339
+ expirationDate: Date.now() - 1000000
340
+ });
341
+ });
342
+
343
+ act(() => {
344
+ jest.advanceTimersByTime(1000);
345
+ });
346
+
347
+ expect(result.current.messages.length).toBe(0);
348
+ jest.useRealTimers();
349
+ });
350
+
351
+ it('handles registered types filtering', async () => {
352
+ const { result } = renderHook(() =>
353
+ useBroadcastChannel('test-channel', { registeredTypes: ['allowed-type'] })
354
+ );
355
+ await waitForChannel();
356
+ const channel = mockChannels[0];
357
+
358
+ act(() => {
359
+ channel.simulateMessage({
360
+ id: 'unregistered',
361
+ type: 'unregistered-type',
362
+ message: 'test',
363
+ source: 'another-tab',
364
+ timestamp: Date.now()
365
+ });
366
+ });
367
+
368
+ expect(result.current.messages.length).toBe(0);
369
+
370
+ act(() => {
371
+ channel.simulateMessage({
372
+ id: 'registered',
373
+ type: 'allowed-type',
374
+ message: 'test',
375
+ source: 'another-tab',
376
+ timestamp: Date.now()
377
+ });
378
+ });
379
+
380
+ expect(result.current.messages.length).toBe(1);
381
+ });
382
+
383
+ it('handles keepLatestMessage option', async () => {
384
+ const { result } = renderHook(() =>
385
+ useBroadcastChannel('test-channel', { keepLatestMessage: true })
386
+ );
387
+ await waitForChannel();
388
+ const channel = mockChannels[0];
389
+
390
+ act(() => {
391
+ channel.simulateMessage({
392
+ id: 'msg1',
393
+ type: 'test',
394
+ message: 'first',
395
+ source: 'another-tab',
396
+ timestamp: Date.now()
397
+ });
398
+ channel.simulateMessage({
399
+ id: 'msg2',
400
+ type: 'test',
401
+ message: 'second',
402
+ source: 'another-tab',
403
+ timestamp: Date.now()
404
+ });
405
+ });
406
+
407
+ expect(result.current.messages.length).toBe(1);
408
+ expect(result.current.messages[0].message).toBe('second');
409
+ });
410
+
411
+ it('handles cleanup debounce', async () => {
412
+ jest.useFakeTimers();
413
+ const { result } = renderHook(() =>
414
+ useBroadcastChannel('test-channel', {
415
+ cleaningInterval: 1000,
416
+ cleanupDebounceMs: 500
417
+ })
418
+ );
419
+ await waitForChannel();
420
+ const channel = mockChannels[0];
421
+
422
+ act(() => {
423
+ channel.simulateMessage({
424
+ id: 'expired-3',
425
+ type: 'test',
426
+ message: 'expired',
427
+ source: 'another-tab',
428
+ timestamp: Date.now() - 1000000,
429
+ expirationDate: Date.now() - 1000000
430
+ });
431
+ });
432
+
433
+ act(() => {
434
+ jest.advanceTimersByTime(1500);
435
+ });
436
+ await Promise.resolve();
437
+
438
+ expect(result.current.messages.length).toBe(0);
439
+ jest.useRealTimers();
440
+ });
441
+
442
+ it('handles error when posting message fails', async () => {
443
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
444
+ await waitForChannel();
445
+ const channel = mockChannels[0];
446
+
447
+ channel.postMessage.mockImplementationOnce(() => {
448
+ throw new Error('Failed to post message');
449
+ });
450
+
451
+ act(() => {
452
+ result.current.postMessage('test', { data: 'test' });
453
+ });
454
+
455
+ expect(result.current.error).toBe('Failed to send message');
456
+ });
457
+
458
+ it('handles error when processing message fails', async () => {
459
+ jest.useFakeTimers();
460
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
461
+ await waitForChannel();
462
+ const channel = mockChannels[0];
463
+
464
+ const badMessage: any = {};
465
+ Object.defineProperty(badMessage, 'id', {
466
+ get() { throw new Error('fail'); }
467
+ });
468
+ badMessage.type = 'test';
469
+ badMessage.source = 'z';
470
+ badMessage.timestamp = Date.now();
471
+
472
+ await act(async () => {
473
+ channel.simulateMessage(badMessage);
474
+ await Promise.resolve();
475
+ });
476
+ await Promise.resolve();
477
+ expect(result.current.error).toBe('Error processing broadcast message');
478
+ jest.useRealTimers();
479
+ });
480
+
481
+ it('handles error when BroadcastChannel initialization fails', () => {
482
+ const originalBroadcastChannel = global.BroadcastChannel;
483
+ delete (global as any).BroadcastChannel;
484
+
485
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
486
+
487
+ expect(result.current.error).toBe('BroadcastChannel is not supported in this browser. Please check browser compatibility.');
488
+
489
+ global.BroadcastChannel = originalBroadcastChannel;
490
+ });
491
+
492
+ it('creates message with expiration duration', async () => {
493
+ const { result } = renderHook(() => useBroadcastChannel('test-channel'));
494
+ await waitForChannel();
495
+
496
+ act(() => {
497
+ result.current.postMessage('test', { data: 'test' }, { expirationDuration: 5000 });
498
+ });
499
+
500
+ const sentMessage = result.current.sentMessages[0];
501
+ expect(sentMessage.expirationDate).toBeDefined();
502
+ expect(sentMessage.expirationDate).toBeGreaterThan(Date.now());
503
+ });
504
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Options for configuring the broadcast channel
3
+ */
4
+ export interface BroadcastOptions {
5
+ /** Custom source name for this instance */
6
+ sourceName?: string;
7
+
8
+ /** Interval in milliseconds for cleaning expired messages (default: 1000) */
9
+ cleaningInterval?: number;
10
+
11
+ /** If true, only the latest message is kept */
12
+ keepLatestMessage?: boolean;
13
+
14
+ /** Array of allowed message types */
15
+ registeredTypes?: string[];
16
+
17
+ /** Channel namespace for isolation */
18
+ namespace?: string;
19
+
20
+ /** TTL for deduplication in milliseconds (default: 5 minutes) */
21
+ deduplicationTTL?: number;
22
+
23
+ /** Debounce time for messages cleanup in milliseconds (default: 1000) */
24
+ cleanupDebounceMs?: number;
25
+ }
26
+
27
+ /**
28
+ * Options for sending messages
29
+ */
30
+ export interface SendMessageOptions {
31
+ /** Message expiration duration in milliseconds */
32
+ expirationDuration?: number;
33
+
34
+ /** Specific expiration timestamp */
35
+ expirationDate?: number;
36
+ }
37
+
38
+ /**
39
+ * Structure of a broadcast message
40
+ */
41
+ export interface BroadcastMessage {
42
+ /** Unique message identifier */
43
+ id: string;
44
+
45
+ /** Message type for categorization */
46
+ type: string;
47
+
48
+ /** Message content */
49
+ message: any;
50
+
51
+ /** Creation timestamp */
52
+ timestamp: number;
53
+
54
+ /** Source identifier */
55
+ source: string;
56
+
57
+ /** Optional expiration timestamp */
58
+ expirationDate?: number;
59
+ }
60
+
61
+ /**
62
+ * Actions and state provided by the broadcast channel hook
63
+ */
64
+ export interface BroadcastActions {
65
+ /** The resolved channel name including namespace */
66
+ channelName: string;
67
+
68
+ /** Array of received messages */
69
+ messages: BroadcastMessage[];
70
+
71
+ /** Array of sent messages */
72
+ sentMessages: BroadcastMessage[];
73
+
74
+ /** Function to send a message */
75
+ postMessage: (
76
+ messageType: string,
77
+ messageContent: any,
78
+ options?: SendMessageOptions
79
+ ) => void;
80
+
81
+ /** Function to clear a specific message */
82
+ clearMessage: (id: string) => void;
83
+
84
+ /** Function to clear all messages */
85
+ clearAllMessages: () => void;
86
+
87
+ /** Function to clear a specific sent message */
88
+ clearSentMessage: (id: string) => void;
89
+
90
+ /** Current error state */
91
+ error: string | null;
92
+ }
@@ -0,0 +1,47 @@
1
+ declare const process: { env: { [key: string]: string | undefined } };
2
+
3
+ const isDebugMode = () => {
4
+ if (typeof process !== 'undefined' && process.env) {
5
+ return process.env.REACT_APP_DEBUG_BROADCAST === 'true';
6
+ }
7
+ return false;
8
+ };
9
+
10
+ type LogLevel = 'info' | 'warn' | 'error';
11
+
12
+ const log = (level: LogLevel, message: string, data?: any) => {
13
+ if (!isDebugMode()) return;
14
+
15
+ const timestamp = new Date().toISOString();
16
+ const prefix = `[${timestamp}] [react-broadcast-sync]`;
17
+ switch (level) {
18
+ case 'info':
19
+ console.log(prefix, message, data || '');
20
+ break;
21
+ case 'warn':
22
+ console.warn(prefix, message, data || '');
23
+ break;
24
+ case 'error':
25
+ console.error(prefix, message, data || '');
26
+ break;
27
+ }
28
+ };
29
+
30
+ export const debug = {
31
+ channel: {
32
+ created: (channelName: string) => log('info', 'Channel created:', channelName),
33
+ closed: (channelName: string) => log('info', 'Channel closed:', channelName),
34
+ },
35
+ message: {
36
+ sent: (message: any) => log('info', 'Message sent:', message),
37
+ received: (message: any) => log('info', 'Message received:', message),
38
+ cleared: (messageId: string) => log('info', 'Message cleared:', messageId),
39
+ expired: (messageId: string) => log('info', 'Message expired:', messageId),
40
+ duplicate: (messageId: string) => log('warn', 'Duplicate message ignored:', messageId),
41
+ },
42
+ cleanup: {
43
+ started: () => log('info', 'Cleanup started'),
44
+ completed: (removedCount: number) => log('info', 'Cleanup completed:', { removedCount }),
45
+ },
46
+ error: (error: string) => log('error', 'Error:', error),
47
+ };