From 0eb73a4f428d854910f40795f5a4f7533c4cea69 Mon Sep 17 00:00:00 2001 From: Denis Valcke Date: Thu, 17 Oct 2024 09:05:21 +0200 Subject: [PATCH 1/4] feat(utils): the utils now contain a service that wraps around the BroadcastChannel API --- .../broadcast-channel/broadcast-channel.md | 156 +++++++++++++++ .../broadcast-channel.service.spec.ts | 182 ++++++++++++++++++ .../broadcast-channel.service.ts | 95 +++++++++ libs/utils/src/lib/services/index.ts | 1 + 4 files changed, 434 insertions(+) create mode 100644 libs/utils/src/lib/services/broadcast-channel/broadcast-channel.md create mode 100644 libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.spec.ts create mode 100644 libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.ts diff --git a/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.md b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.md new file mode 100644 index 00000000..c794e6dc --- /dev/null +++ b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.md @@ -0,0 +1,156 @@ +# BroadcastChannelService + +This `BroadcastChannelService` service wraps around the [BroadcastChannel API](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API) and provides some handy functionality on top of some safety measures. It take SSR into account and will only create channels while in the browser. + +It holds a Record of potential BroadcastChannels with the key being their name. By doing this, multiple channels can exist within the same application simultaneously. + +## Methods + +### initChannel + +The `initChannel` method will create a new BroadcastChannel with the given name. + +```typescript +import { BroadcastChannelService } from '@studiohyperdrive/ngx-utils'; + +export class YourComponent { + constructor(private readonly broadcastChannelService: BroadcastChannelService) {} + + public ngOnInit(): void { + this.broadcastChannelService.initChannel('your-channel-name'); + } +} +``` + +#### Safety + +The `initChannel` uses the `isPlatformBrowser` check to ensure it only runs in the browser, taking SSR into account as the BroadcastChannel API is not available in node by default. + +If the channel already exists, it will return the existing channel to avoid overriding existing channels and listeners. + +If a name is not provided, it will early return and log a warning: + +``` +channelName is required +``` + +### closeChannel + +The `closeChannel` will close a channel with the given name. + +```typescript +import { BroadcastChannelService } from '@studiohyperdrive/ngx-utils'; + +export class YourComponent { + constructor(private readonly broadcastChannelService: BroadcastChannelService) {} + + public ngOnInit(): void { + // Open up a channel for this component OnInit. + this.broadcastChannelService.initChannel('your-channel-name'); + } + + public ngOnDestroy(): void { + // Close the created channel OnDestroy. + this.broadcastChannelService.closeChannel('your-channel-name'); + } +} +``` + +#### Safety + +If the channel does not exist on the Record, it will early return. + +If a name is not provided, it will early return and log a warning: + +``` +channelName is required +``` + +### postMessage + +The `postMessage` method will post a message to a channel with the given name. + +```typescript +import { BroadcastChannelService } from '@studiohyperdrive/ngx-utils'; + +export class YourComponent { + constructor(private readonly broadcastChannelService: BroadcastChannelService) {} + + public ngOnInit(): void { + // Open up a channel for this component OnInit. + this.broadcastChannelService.initChannel('your-channel-name'); + } + + public ngOnDestroy(): void { + // Close the created channel OnDestroy. + this.broadcastChannelService.closeChannel('your-channel-name'); + } + + public sendContextMessage(message: string): void { + // Send a message through the channel. + this.broadcastChannelService.postMessage('your-channel-name', message); + } +} +``` + +#### Safety + +If the channel does not exist on the Record, it will early return and log a warning to give a notice that the channel has not been initialized. + +``` +BroadcastChannel not initialized, message not sent +``` + +_This warning will include the message that has been included to give a better understanding of what message was not sent._ + +If a name is not provided, it will early return and log a warning. + +```angular2html +channelName is required +``` + +### selectChannel + +The `selectChannel` method will return a subscription wrapped around the `message` event of the channel with the given name. + +```typescript +import { BroadcastChannelService } from '@studiohyperdrive/ngx-utils'; + +export class YourComponent { + constructor(private readonly broadcastChannelService: BroadcastChannelService) {} + + public ngOnInit(): void { + // Open up a channel for this component OnInit. + this.broadcastChannelService.initChannel('your-channel-name'); + + this.broadcastChannelService.selectChannel('your-channel-name').subscribe({ + // Handle the message event. + next: (message: MessageEvent) => { + console.log(message.data); + }, + // When the channelName is not provided, an EMPTY is returned to not break the subscription. + complete: () => { + console.log('No channelName provided to the selectChannel method'); + }, + }); + } + + public ngOnDestroy(): void { + // Close the created channel OnDestroy. + this.broadcastChannelService.closeChannel('your-channel-name'); + } + + public sendContextMessage(message: string): void { + // Send a message through the channel. + this.broadcastChannelService.postMessage('your-channel-name', message); + } +} +``` + +#### Safety + +If a name is not provided, it will early return an `EMPTY` and log a warning. + +```angular2html +channelName is required +``` diff --git a/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.spec.ts b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.spec.ts new file mode 100644 index 00000000..da023009 --- /dev/null +++ b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.spec.ts @@ -0,0 +1,182 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { Subscription } from 'rxjs'; +import { BroadcastChannelService } from './broadcast-channel.service'; + +class MockBroadcastChannel { + private listeners: { [key: string]: Function[] } = {}; + + constructor(public name: string) {} + + postMessage(message: any) { + if (this.listeners['message']) { + this.listeners['message'].forEach((listener) => listener({ data: message })); + } + } + + close() { + this.listeners = {}; + } + + addEventListener(event: string, listener: Function) { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + + this.listeners[event].push(listener); + } + + removeEventListener(event: string, listener: Function) { + if (!this.listeners[event]) { + return; + } + + this.listeners[event] = this.listeners[event].filter((l) => l !== listener); + } +} + +// Replace the global BroadcastChannel with the mock +(globalThis as any).BroadcastChannel = MockBroadcastChannel; +// Prevent the window from reloading +window.onbeforeunload = jasmine.createSpy(); + +describe('BroadcastChannelService', () => { + describe('in browser', () => { + let service: BroadcastChannelService; + let subscriptions: Subscription[] = []; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [BroadcastChannelService, { provide: PLATFORM_ID, useValue: 'browser' }], + }); + + service = TestBed.inject(BroadcastChannelService); + }); + + afterEach(() => { + subscriptions.forEach((sub) => sub.unsubscribe()); + subscriptions = []; + }); + + describe('initChannel', () => { + it('should return early if channelName is not provided', () => { + const consoleWarnSpy = spyOn(console, 'warn'); + + service.initChannel(''); + + expect(consoleWarnSpy).toHaveBeenCalledWith('channelName is required'); + }); + + it('should initialize a new BroadcastChannel instance', () => { + service.initChannel('testChannel'); + + expect(service['broadcastChannel']['testChannel']).toBeDefined(); + }); + }); + + describe('closeChannel', () => { + it('should return early if channelName is not provided', () => { + const consoleWarnSpy = spyOn(console, 'warn'); + + service.closeChannel(''); + + expect(consoleWarnSpy).toHaveBeenCalledWith('channelName is required'); + }); + + it('should return early if channel is not initialized', () => { + service.closeChannel('nonExistentChannel'); + + expect(service['broadcastChannel']['nonExistentChannel']).toBeUndefined(); + }); + + it('should close a selected BroadcastChannel instance', () => { + service.initChannel('testChannel'); + service.closeChannel('testChannel'); + + expect(service['broadcastChannel']['testChannel']).toBeUndefined(); + }); + }); + + describe('postMessage', () => { + it('should return early if channelName is not provided', () => { + const consoleWarnSpy = spyOn(console, 'warn'); + + service.postMessage('', 'message'); + + expect(consoleWarnSpy).toHaveBeenCalledWith('channelName is required'); + }); + + it('should return early if channel is not initialized', () => { + const consoleWarnSpy = spyOn(console, 'warn'); + + service.postMessage('nonExistentChannel', 'message'); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'BroadcastChannel not initialized, message not sent', + 'message' + ); + }); + + it('should send a message to a selected BroadcastChannel instance', (done) => { + service.initChannel('testChannel'); + + service['broadcastChannel'].testChannel.addEventListener('message', (event) => { + expect(event.data).toBe('message'); + done(); + }); + + service.postMessage('testChannel', 'message'); + }); + }); + + describe('selectChannel', () => { + it('should return early if channelName is not provided', (done) => { + const consoleWarnSpy = spyOn(console, 'warn'); + subscriptions.push( + service.selectChannel('').subscribe({ + complete: () => { + expect(consoleWarnSpy).toHaveBeenCalledWith('channelName is required'); + + done(); + }, + }) + ); + }); + + it('should select the broadcast channel and return an observable of its message event', (done) => { + service.initChannel('testChannel'); + + subscriptions.push( + service.selectChannel('testChannel').subscribe((event) => { + expect(event.data).toBe('message'); + + done(); + }) + ); + + service.postMessage('testChannel', 'message'); + }); + }); + }); + + describe('not in browser', () => { + let service: BroadcastChannelService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [BroadcastChannelService, { provide: PLATFORM_ID, useValue: 'server' }], + }); + + service = TestBed.inject(BroadcastChannelService); + }); + + describe('initChannel', () => { + it('should return early if platform is not browser', () => { + service.initChannel('testChannel'); + + expect(service['broadcastChannel']['testChannel']).toBeUndefined(); + }); + }); + }); +}); diff --git a/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.ts b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.ts new file mode 100644 index 00000000..f211146c --- /dev/null +++ b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.ts @@ -0,0 +1,95 @@ +import { isPlatformBrowser } from '@angular/common'; +import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { EMPTY, fromEvent, Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class BroadcastChannelService { + private broadcastChannel: Record = {}; + + constructor(@Inject(PLATFORM_ID) private platformId: string) {} + + /** + * initChannel + * + * The initChannel method initializes a new BroadcastChannel instance. + * + * @param channelName{string} - The name of the Broadcast Channel. + */ + public initChannel(channelName: string): void { + if (!channelName) { + console.warn('channelName is required'); + + return; + } + + if (isPlatformBrowser(this.platformId) && !this.broadcastChannel[channelName]) { + this.broadcastChannel[channelName] = new BroadcastChannel(channelName); + } + } + + /** + * closeChannel + * + * The closeChannel method closes a selected BroadcastChannel instance. + * + * @param channelName{string} - The name of the Broadcast Channel. + */ + public closeChannel(channelName: string): void { + if (!channelName) { + console.warn('channelName is required'); + + return; + } + + if (!this.broadcastChannel[channelName]) { + return; + } + + this.broadcastChannel[channelName].close(); + delete this.broadcastChannel[channelName]; + } + + /** + * postMessage + * + * The postMessage method sends a message to a selected BroadcastChannel instance. + * + * @param channelName{string} - The name of the Broadcast Channel. + * @param message{any} - The payload to send through the channel. + */ + public postMessage(channelName: string, message: any): void { + if (!channelName) { + console.warn('channelName is required'); + + return; + } + + if (!this.broadcastChannel[channelName]) { + console.warn('BroadcastChannel not initialized, message not sent', message); + + return; + } + + this.broadcastChannel[channelName].postMessage(message); + } + + /** + * subscribeToChannel + * + * The subscribeToChannel method subscribes to the `message` (bc.onmessage) event of a selected BroadcastChannel instance. + * + * @param channelName{string} - The name of the Broadcast Channel. + * @returns Observable - The message event of the channel wrapped in an observable. + */ + public selectChannel(channelName: string): Observable { + if (!channelName) { + console.warn('channelName is required'); + + return EMPTY; + } + + return fromEvent(this.broadcastChannel[channelName], 'message'); + } +} diff --git a/libs/utils/src/lib/services/index.ts b/libs/utils/src/lib/services/index.ts index 2eef8d2b..2d5fbdde 100644 --- a/libs/utils/src/lib/services/index.ts +++ b/libs/utils/src/lib/services/index.ts @@ -1,6 +1,7 @@ export { WindowService } from './window-service/window.service'; export { windowMock, windowServiceMock } from './window-service/window.service.mock'; +export { BroadcastChannelService } from './broadcast-channel/broadcast-channel.service'; export { SubscriptionService } from './subscription-service/subscription.service'; export { NgxStorageService } from './storage-service/storage.service'; export { NgxMediaQueryService } from './media-query/mediaquery.service'; From 60a7b445fcecca2ef3f6140e1f060b88f602390f Mon Sep 17 00:00:00 2001 From: Denis Valcke Date: Thu, 17 Oct 2024 11:13:04 +0200 Subject: [PATCH 2/4] feat(utils): several improvements have been made to the NgxBroadcastChannelService including simplified safety and fully supporting the spec --- libs/utils/README.md | 6 + libs/utils/package.json | 2 + .../broadcast-channel/broadcast-channel.md | 20 +-- .../broadcast-channel.service.spec.ts | 131 +++++++++++++----- .../broadcast-channel.service.ts | 75 ++++++---- libs/utils/src/lib/services/index.ts | 2 +- 6 files changed, 160 insertions(+), 76 deletions(-) diff --git a/libs/utils/README.md b/libs/utils/README.md index 001d2b53..29aacdb3 100644 --- a/libs/utils/README.md +++ b/libs/utils/README.md @@ -193,6 +193,12 @@ This service provides a SSR proof way to set and subscribe to the window's media [Full documentation.](src/lib/services/media-query/query.service.md) +#### BroadcastChannelService + +This service provides a SSR proof way to create channels for the BroadcastChannel API, post messages on those channels and subscribe to the events. + +[Full documentation.](src/lib/services/broadcast-channel/broadcast-channel.md) + ### Abstracts #### NgxQueryParamFormSyncComponent diff --git a/libs/utils/package.json b/libs/utils/package.json index 8ab38eb1..d1c67d9c 100644 --- a/libs/utils/package.json +++ b/libs/utils/package.json @@ -8,6 +8,8 @@ "utils", "storage", "storage service", + "broadcast channel", + "bradcast channel service", "consent", "join", "iban", diff --git a/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.md b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.md index c794e6dc..7aa22d09 100644 --- a/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.md +++ b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.md @@ -1,6 +1,6 @@ -# BroadcastChannelService +# NgxBroadcastChannelService -This `BroadcastChannelService` service wraps around the [BroadcastChannel API](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API) and provides some handy functionality on top of some safety measures. It take SSR into account and will only create channels while in the browser. +This `NgxBroadcastChannelService` service wraps around the [BroadcastChannel API](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API) and provides some handy functionality on top of some safety measures. It takes SSR into account and will only create channels while in the browser. It holds a Record of potential BroadcastChannels with the key being their name. By doing this, multiple channels can exist within the same application simultaneously. @@ -11,10 +11,10 @@ It holds a Record of potential BroadcastChannels with the key being their name. The `initChannel` method will create a new BroadcastChannel with the given name. ```typescript -import { BroadcastChannelService } from '@studiohyperdrive/ngx-utils'; +import { NgxBroadcastChannelService } from '@studiohyperdrive/ngx-utils'; export class YourComponent { - constructor(private readonly broadcastChannelService: BroadcastChannelService) {} + constructor(private readonly broadcastChannelService: NgxBroadcastChannelService) {} public ngOnInit(): void { this.broadcastChannelService.initChannel('your-channel-name'); @@ -39,10 +39,10 @@ channelName is required The `closeChannel` will close a channel with the given name. ```typescript -import { BroadcastChannelService } from '@studiohyperdrive/ngx-utils'; +import { NgxBroadcastChannelService } from '@studiohyperdrive/ngx-utils'; export class YourComponent { - constructor(private readonly broadcastChannelService: BroadcastChannelService) {} + constructor(private readonly broadcastChannelService: NgxBroadcastChannelService) {} public ngOnInit(): void { // Open up a channel for this component OnInit. @@ -71,10 +71,10 @@ channelName is required The `postMessage` method will post a message to a channel with the given name. ```typescript -import { BroadcastChannelService } from '@studiohyperdrive/ngx-utils'; +import { NgxBroadcastChannelService } from '@studiohyperdrive/ngx-utils'; export class YourComponent { - constructor(private readonly broadcastChannelService: BroadcastChannelService) {} + constructor(private readonly broadcastChannelService: NgxBroadcastChannelService) {} public ngOnInit(): void { // Open up a channel for this component OnInit. @@ -114,10 +114,10 @@ channelName is required The `selectChannel` method will return a subscription wrapped around the `message` event of the channel with the given name. ```typescript -import { BroadcastChannelService } from '@studiohyperdrive/ngx-utils'; +import { NgxBroadcastChannelService } from '@studiohyperdrive/ngx-utils'; export class YourComponent { - constructor(private readonly broadcastChannelService: BroadcastChannelService) {} + constructor(private readonly broadcastChannelService: NgxBroadcastChannelService) {} public ngOnInit(): void { // Open up a channel for this component OnInit. diff --git a/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.spec.ts b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.spec.ts index da023009..1241af5a 100644 --- a/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.spec.ts +++ b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.spec.ts @@ -1,25 +1,23 @@ -import { PLATFORM_ID } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; - import { Subscription } from 'rxjs'; -import { BroadcastChannelService } from './broadcast-channel.service'; +import { windowServiceMock } from '../window-service/window.service.mock'; +import { NgxBroadcastChannelService } from './broadcast-channel.service'; class MockBroadcastChannel { private listeners: { [key: string]: Function[] } = {}; constructor(public name: string) {} - postMessage(message: any) { + public postMessage(message: any): void { if (this.listeners['message']) { this.listeners['message'].forEach((listener) => listener({ data: message })); } } - close() { + public close(): void { this.listeners = {}; } - addEventListener(event: string, listener: Function) { + public addEventListener(event: string, listener: Function): void { if (!this.listeners[event]) { this.listeners[event] = []; } @@ -27,7 +25,7 @@ class MockBroadcastChannel { this.listeners[event].push(listener); } - removeEventListener(event: string, listener: Function) { + public removeEventListener(event: string, listener: Function): void { if (!this.listeners[event]) { return; } @@ -41,17 +39,13 @@ class MockBroadcastChannel { // Prevent the window from reloading window.onbeforeunload = jasmine.createSpy(); -describe('BroadcastChannelService', () => { +describe('NgxBroadcastChannelService', () => { describe('in browser', () => { - let service: BroadcastChannelService; + let service: NgxBroadcastChannelService; let subscriptions: Subscription[] = []; beforeEach(() => { - TestBed.configureTestingModule({ - providers: [BroadcastChannelService, { provide: PLATFORM_ID, useValue: 'browser' }], - }); - - service = TestBed.inject(BroadcastChannelService); + service = new NgxBroadcastChannelService(windowServiceMock(undefined) as any); }); afterEach(() => { @@ -61,11 +55,13 @@ describe('BroadcastChannelService', () => { describe('initChannel', () => { it('should return early if channelName is not provided', () => { - const consoleWarnSpy = spyOn(console, 'warn'); + const consoleSpy = spyOn(console, 'error'); service.initChannel(''); - expect(consoleWarnSpy).toHaveBeenCalledWith('channelName is required'); + expect(consoleSpy).toHaveBeenCalledWith( + 'NgxUtils: There was an attempt to initialize a BroadcastChannel without providing a name.' + ); }); it('should initialize a new BroadcastChannel instance', () => { @@ -77,11 +73,9 @@ describe('BroadcastChannelService', () => { describe('closeChannel', () => { it('should return early if channelName is not provided', () => { - const consoleWarnSpy = spyOn(console, 'warn'); - service.closeChannel(''); - expect(consoleWarnSpy).toHaveBeenCalledWith('channelName is required'); + expect(service['broadcastChannel']['nonExistentChannel']).toBeUndefined(); }); it('should return early if channel is not initialized', () => { @@ -100,20 +94,23 @@ describe('BroadcastChannelService', () => { describe('postMessage', () => { it('should return early if channelName is not provided', () => { - const consoleWarnSpy = spyOn(console, 'warn'); + const consoleSpy = spyOn(console, 'error'); service.postMessage('', 'message'); - expect(consoleWarnSpy).toHaveBeenCalledWith('channelName is required'); + expect(consoleSpy).toHaveBeenCalledWith( + 'NgxUtils: There was an attempt to post a message to a channel without providing a name or the selected channel does not exist. The included message was:', + 'message' + ); }); it('should return early if channel is not initialized', () => { - const consoleWarnSpy = spyOn(console, 'warn'); + const consoleSpy = spyOn(console, 'error'); service.postMessage('nonExistentChannel', 'message'); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'BroadcastChannel not initialized, message not sent', + expect(consoleSpy).toHaveBeenCalledWith( + 'NgxUtils: There was an attempt to post a message to a channel without providing a name or the selected channel does not exist. The included message was:', 'message' ); }); @@ -130,13 +127,30 @@ describe('BroadcastChannelService', () => { }); }); - describe('selectChannel', () => { + describe('selectChannelMessages', () => { it('should return early if channelName is not provided', (done) => { - const consoleWarnSpy = spyOn(console, 'warn'); + const consoleSpy = spyOn(console, 'error'); + subscriptions.push( + service.selectChannelMessages('').subscribe({ + complete: () => { + expect(consoleSpy).toHaveBeenCalledWith( + "NgxUtils: There was an attempt to select a BroadcastChannel's messages without providing a name or the selected channel does not exist." + ); + + done(); + }, + }) + ); + }); + + it('should return early if the channel does not exist', (done) => { + const consoleSpy = spyOn(console, 'error'); subscriptions.push( - service.selectChannel('').subscribe({ + service.selectChannelMessages('testChannel').subscribe({ complete: () => { - expect(consoleWarnSpy).toHaveBeenCalledWith('channelName is required'); + expect(consoleSpy).toHaveBeenCalledWith( + "NgxUtils: There was an attempt to select a BroadcastChannel's messages without providing a name or the selected channel does not exist." + ); done(); }, @@ -148,7 +162,53 @@ describe('BroadcastChannelService', () => { service.initChannel('testChannel'); subscriptions.push( - service.selectChannel('testChannel').subscribe((event) => { + service.selectChannelMessages('testChannel').subscribe((event) => { + expect(event.data).toBe('message'); + + done(); + }) + ); + + service.postMessage('testChannel', 'message'); + }); + }); + + describe('selectChannelMessageErrors', () => { + it('should return early if channelName is not provided', (done) => { + const consoleSpy = spyOn(console, 'error'); + subscriptions.push( + service.selectChannelMessageErrors('').subscribe({ + complete: () => { + expect(consoleSpy).toHaveBeenCalledWith( + "NgxUtils: There was an attempt to select a BroadcastChannel's message errors without providing a name or the selected channel does not exist." + ); + + done(); + }, + }) + ); + }); + + it('should return early if the channel does not exist', (done) => { + const consoleSpy = spyOn(console, 'error'); + subscriptions.push( + service.selectChannelMessageErrors('testChannel').subscribe({ + complete: () => { + expect(consoleSpy).toHaveBeenCalledWith( + "NgxUtils: There was an attempt to select a BroadcastChannel's message errors without providing a name or the selected channel does not exist." + ); + + done(); + }, + }) + ); + }); + + xit('should select the broadcast channel and return an observable of its message event', (done) => { + service.initChannel('testChannel'); + + subscriptions.push( + service.selectChannelMessageErrors('testChannel').subscribe((event) => { expect(event.data).toBe('message'); done(); @@ -161,14 +221,13 @@ describe('BroadcastChannelService', () => { }); describe('not in browser', () => { - let service: BroadcastChannelService; + let service: NgxBroadcastChannelService; - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [BroadcastChannelService, { provide: PLATFORM_ID, useValue: 'server' }], - }); + const windowService = windowServiceMock(undefined); + windowService.isBrowser = () => false; - service = TestBed.inject(BroadcastChannelService); + beforeEach(() => { + service = new NgxBroadcastChannelService(windowService as any); }); describe('initChannel', () => { diff --git a/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.ts b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.ts index f211146c..2f614acf 100644 --- a/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.ts +++ b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.service.ts @@ -1,31 +1,35 @@ -import { isPlatformBrowser } from '@angular/common'; -import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { Injectable } from '@angular/core'; import { EMPTY, fromEvent, Observable } from 'rxjs'; +import { WindowService } from '../window-service/window.service'; @Injectable({ providedIn: 'root', }) -export class BroadcastChannelService { +export class NgxBroadcastChannelService { private broadcastChannel: Record = {}; - constructor(@Inject(PLATFORM_ID) private platformId: string) {} + constructor(private readonly windowService: WindowService) {} /** * initChannel * * The initChannel method initializes a new BroadcastChannel instance. * - * @param channelName{string} - The name of the Broadcast Channel. + * @param args{ConstructorParameters} - The arguments to pass to the BroadcastChannel constructor. */ - public initChannel(channelName: string): void { + public initChannel(...args: ConstructorParameters): void { + const [channelName] = args; + if (!channelName) { - console.warn('channelName is required'); + console.error( + 'NgxUtils: There was an attempt to initialize a BroadcastChannel without providing a name.' + ); return; } - if (isPlatformBrowser(this.platformId) && !this.broadcastChannel[channelName]) { - this.broadcastChannel[channelName] = new BroadcastChannel(channelName); + if (this.windowService.isBrowser() && !this.broadcastChannel[channelName]) { + this.broadcastChannel[channelName] = new BroadcastChannel(...args); } } @@ -37,13 +41,7 @@ export class BroadcastChannelService { * @param channelName{string} - The name of the Broadcast Channel. */ public closeChannel(channelName: string): void { - if (!channelName) { - console.warn('channelName is required'); - - return; - } - - if (!this.broadcastChannel[channelName]) { + if (!channelName || !this.broadcastChannel[channelName]) { return; } @@ -60,14 +58,11 @@ export class BroadcastChannelService { * @param message{any} - The payload to send through the channel. */ public postMessage(channelName: string, message: any): void { - if (!channelName) { - console.warn('channelName is required'); - - return; - } - - if (!this.broadcastChannel[channelName]) { - console.warn('BroadcastChannel not initialized, message not sent', message); + if (!channelName || !this.broadcastChannel[channelName]) { + console.error( + 'NgxUtils: There was an attempt to post a message to a channel without providing a name or the selected channel does not exist. The included message was:', + message + ); return; } @@ -76,20 +71,42 @@ export class BroadcastChannelService { } /** - * subscribeToChannel + * selectChannelMessages * - * The subscribeToChannel method subscribes to the `message` (bc.onmessage) event of a selected BroadcastChannel instance. + * The selectChannelMessages method subscribes to the `message` (bc.onmessage) event of a selected BroadcastChannel instance. * * @param channelName{string} - The name of the Broadcast Channel. * @returns Observable - The message event of the channel wrapped in an observable. */ - public selectChannel(channelName: string): Observable { - if (!channelName) { - console.warn('channelName is required'); + public selectChannelMessages(channelName: string): Observable { + if (!channelName || !this.broadcastChannel[channelName]) { + console.error( + "NgxUtils: There was an attempt to select a BroadcastChannel's messages without providing a name or the selected channel does not exist." + ); return EMPTY; } return fromEvent(this.broadcastChannel[channelName], 'message'); } + + /** + * selectChannelMessageErrors + * + * The selectChannelMessageErrors method subscribes to the `messageerror` (bc.onmessageerror) event of a selected BroadcastChannel instance. + * + * @param channelName{string} - The name of the Broadcast Channel. + * @returns Observable - The messageerror event of the channel wrapped in an observable. + */ + public selectChannelMessageErrors(channelName: string): Observable { + if (!channelName || !this.broadcastChannel[channelName]) { + console.error( + "NgxUtils: There was an attempt to select a BroadcastChannel's message errors without providing a name or the selected channel does not exist." + ); + + return EMPTY; + } + + return fromEvent(this.broadcastChannel[channelName], 'messageerror'); + } } diff --git a/libs/utils/src/lib/services/index.ts b/libs/utils/src/lib/services/index.ts index 2d5fbdde..51132887 100644 --- a/libs/utils/src/lib/services/index.ts +++ b/libs/utils/src/lib/services/index.ts @@ -1,7 +1,7 @@ export { WindowService } from './window-service/window.service'; export { windowMock, windowServiceMock } from './window-service/window.service.mock'; -export { BroadcastChannelService } from './broadcast-channel/broadcast-channel.service'; +export { NgxBroadcastChannelService } from './broadcast-channel/broadcast-channel.service'; export { SubscriptionService } from './subscription-service/subscription.service'; export { NgxStorageService } from './storage-service/storage.service'; export { NgxMediaQueryService } from './media-query/mediaquery.service'; From 61e0def7b36af9094a68728ff6245780727cb35a Mon Sep 17 00:00:00 2001 From: Denis Valcke Date: Thu, 17 Oct 2024 11:30:15 +0200 Subject: [PATCH 3/4] feat(utils): updated the NgxBroadcastChannelService README to reflex recent changes --- .../broadcast-channel/broadcast-channel.md | 74 ++++++++++++++----- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.md b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.md index 7aa22d09..2850908b 100644 --- a/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.md +++ b/libs/utils/src/lib/services/broadcast-channel/broadcast-channel.md @@ -28,10 +28,10 @@ The `initChannel` uses the `isPlatformBrowser` check to ensure it only runs in t If the channel already exists, it will return the existing channel to avoid overriding existing channels and listeners. -If a name is not provided, it will early return and log a warning: +If a name is not provided, it will early return and log an error: ``` -channelName is required +NgxUtils: There was an attempt to initialize a BroadcastChannel without providing a name. ``` ### closeChannel @@ -58,13 +58,7 @@ export class YourComponent { #### Safety -If the channel does not exist on the Record, it will early return. - -If a name is not provided, it will early return and log a warning: - -``` -channelName is required -``` +If the channel does not exist on the Record or the name is not provided, it will early return. ### postMessage @@ -95,23 +89,63 @@ export class YourComponent { #### Safety -If the channel does not exist on the Record, it will early return and log a warning to give a notice that the channel has not been initialized. +If the channel does not exist on the Record or the name is not provided, it will early return and log an error to give a notice. ``` -BroadcastChannel not initialized, message not sent +NgxUtils: There was an attempt to post a message to a channel without providing a name or the selected channel does not exist. The included message was: ``` _This warning will include the message that has been included to give a better understanding of what message was not sent._ -If a name is not provided, it will early return and log a warning. +### selectChannelMessages + +The `selectChannelMessages` method will return a subscription wrapped around the `message` event of the channel with the given name. + +```typescript +import { NgxBroadcastChannelService } from '@studiohyperdrive/ngx-utils'; + +export class YourComponent { + constructor(private readonly broadcastChannelService: NgxBroadcastChannelService) {} + + public ngOnInit(): void { + // Open up a channel for this component OnInit. + this.broadcastChannelService.initChannel('your-channel-name'); + + this.broadcastChannelService.selectChannelMessages('your-channel-name').subscribe({ + // Handle the message event. + next: (message: MessageEvent) => { + console.log(message.data); + }, + // When the channelName is not provided, an EMPTY is returned to not break the subscription. + complete: () => { + console.log('No channelName provided to the selectChannel method'); + }, + }); + } + + public ngOnDestroy(): void { + // Close the created channel OnDestroy. + this.broadcastChannelService.closeChannel('your-channel-name'); + } + + public sendContextMessage(message: string): void { + // Send a message through the channel. + this.broadcastChannelService.postMessage('your-channel-name', message); + } +} +``` + +#### Safety + +If the channel does not exist on the Record or the name is not provided, it will early return an `EMPTY` and log an error. ```angular2html -channelName is required +NgxUtils: There was an attempt to select a BroadcastChannel's messages without providing a name or the selected channel does not exist. ``` -### selectChannel +### selectChannelMessageErrors -The `selectChannel` method will return a subscription wrapped around the `message` event of the channel with the given name. +The `selectChannelMessageErrors` method will return a subscription wrapped around the `message` event of the channel with the given name. ```typescript import { NgxBroadcastChannelService } from '@studiohyperdrive/ngx-utils'; @@ -123,10 +157,10 @@ export class YourComponent { // Open up a channel for this component OnInit. this.broadcastChannelService.initChannel('your-channel-name'); - this.broadcastChannelService.selectChannel('your-channel-name').subscribe({ + this.broadcastChannelService.selectChannelMessageErrors('your-channel-name').subscribe({ // Handle the message event. - next: (message: MessageEvent) => { - console.log(message.data); + next: (messageError: MessageEvent) => { + console.log(messageError.data); }, // When the channelName is not provided, an EMPTY is returned to not break the subscription. complete: () => { @@ -149,8 +183,8 @@ export class YourComponent { #### Safety -If a name is not provided, it will early return an `EMPTY` and log a warning. +If the channel does not exist on the Record or the name is not provided, it will early return an `EMPTY` and log an error. ```angular2html -channelName is required +NgxUtils: There was an attempt to select a BroadcastChannel's message errors without providing a name or the selected channel does not exist. ``` From f394fa10e0a361ff93badf6213b5f4a905a127ef Mon Sep 17 00:00:00 2001 From: Denis Valcke Date: Thu, 17 Oct 2024 11:31:20 +0200 Subject: [PATCH 4/4] feat(utils): updated the NgxUtils README to reflex recent changes --- libs/utils/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/utils/README.md b/libs/utils/README.md index 29aacdb3..f6f2f793 100644 --- a/libs/utils/README.md +++ b/libs/utils/README.md @@ -193,7 +193,7 @@ This service provides a SSR proof way to set and subscribe to the window's media [Full documentation.](src/lib/services/media-query/query.service.md) -#### BroadcastChannelService +#### NgxBroadcastChannelService This service provides a SSR proof way to create channels for the BroadcastChannel API, post messages on those channels and subscribe to the events.