BroadcasterJSA Simple Pub/Sub Event Bus

A megaphone

Simplifiy your life today

It is never to late to start simplifying your dev life. Start using the BroadcasterJS pub/sub event bus today!

Install broadcasterjs (or copy code)

npm i @foundit/broadcasterjs

Import broadcaster.ts

import { broadcast } from '@foundit/broadcasterjs'

Subscribe:

const off = broadcast.on(['EXAMPLE-FLAG', ({detail: myData}) => setMyUseState(myData)])

Publish:

broadcast.emit('EXAMPLE-FLAG')

Publish with some payload data:

broadcast.emit('EXAMPLE-FLAG', myData)

Unsubscribe (execute the subscribe return function):

off()

BroadcasterJS is a pub/sub event transmitter written in typescript. A subscriber in one part of your app is always ready to execute a function triggered from another part of your app. With or without arguments. Use it once or architect your whole web app infrastructure around events. Either way this lightweight gem of code will suit your needs.

BroadcasterJS is framework agnostic and therefor doesn't in itself trigger any rerenders. So to get it back into the React realm just put the subscriber in a useEffect like so. Emitters can be trigger anywhere.

In React wrap the subscriber in a useEffect. Return it if the listener should unsubscribe on component unmount. Omit the return if the listener should persist:

useEffect(() => {
    return broadcast.on(['EXAMPLE-FLAG', () => setMyUseState(true)]);
  }, [])

Only one of each subscribers (flag + listener combination) is set. A rerender will not add the same subscriber again.

Setting the flag in upper case is just a best practice which makes for better code readability/­maintainability.

Benefits

Benefits of using a pub/sub pattern in general

  • Creating decoupled components makes for easier refactoring/maintenance.
  • No need for unnecessary prop drilling.
  • Multiple events emitters can trigger a centralized functions placed in logical places instead of practical.
  • Event driven web apps unleashes good DX. (developer happiness)

Benefits of using custom events. (encapsulated in BroadcasterJS)

  • Framework agnostic.
  • Works globally in a micro frontend environment.
  • Scales well.
  • Native. (Ages well.)
  • Native. (Performant)

Prerequisites and requirements before creation of BroadcasterJS

  • Easy to use.
  • No initialization.
  • Plug and play.
  • Inspectable and debuggable.
  • Unintrusive as a dependency.
  • No own dependecies.

Live Example

A click invokes a broadcast (outside of React) that is received in the sibling component where it sets a local state back in React that then triggers a re-render.

Emitter

Receiver

0s since last render

No props where abused in this button click. Scouts honor.

The source code

broadcaster.js | broadcaster.ts


      export type ListenerProps = <T extends unknown>([type, listener, settings]: [
        type: string,
        listener?: T,
        settings?: settingsType
      ]) => string | void
      
      export interface returnType {
        on: ListenerProps
        once: ListenerProps
        off: ListenerProps
        emit: (type: string, detail?: unknown) => boolean
      }
      
      export interface settingsType {
        debug: boolean
        debugGlobal: boolean
        allowDoublettesSubscribers: boolean
      }
      
      let broadcastItemsCache: string[] = []
      
      let globalDebug =
        new URLSearchParams(window.location.search).get('debug')?.toLowerCase() ===
        'broadcasterjs'
      
      const defaultSettings = {
        debug: false,
        debugGlobal: false,
        allowDoublettesSubscribers: false,
      }
      
      const eventBus = (): returnType => {
        const hubId = ' broadcast-node '
        const on = <T extends unknown>([type, listener, settings = defaultSettings]: [
          type: string,
          listener?: T,
          settings?: settingsType
        ]): string => {
          const options = setOptions(settings)
          const { exists, id } = handleCache().listenerExists(type, listener, options)
          if (exists && !options.allowDoublettesSubscribers) return id
          if (options.debug)
            debugmode({
              string: `Setting listener for "${type}"`,
              obj: listener,
              force: true,
            })
          const eventTarget = createOrGetCustomEventNode(hubId)
          eventTarget.addEventListener(
            'broadcast-' + type,
            listener as EventListenerOrEventListenerObject
          )
          return id
        }
        const once = <T extends unknown>([
          type,
          listener,
          settings = defaultSettings,
        ]: [type: string, listener?: T, settings?: settingsType]) => {
          const options = setOptions(settings)
          const { exists, id } = handleCache().listenerExists(type, listener, options)
          if (exists && !options.allowDoublettesSubscribers) return id
          if (options.debug)
            debugmode({
              string: `Setting "once" listener "${type}"`,
              obj: listener,
              force: true,
            })
          const eventTarget = createOrGetCustomEventNode(hubId)
          eventTarget.addEventListener(
            'broadcast-' + type,
            listener as EventListenerOrEventListenerObject,
            { once: true }
          )
          return id
        }
        const off = <T extends unknown>([
          type,
          listener,
          settings = defaultSettings,
        ]: [type: string, listener?: T, settings?: settingsType]) => {
          const options = setOptions(settings)
          if (options.debug)
            debugmode({
              string: `Removing listener "${type}"`,
              obj: listener,
              force: true,
            })
          handleCache().remove(type, listener)
          const eventTarget = createOrGetCustomEventNode(hubId)
          eventTarget.removeEventListener(
            'broadcast-' + type,
            listener as EventListenerOrEventListenerObject
          )
        }
        const emit = (
          type: string,
          detail?: unknown,
          settings?: settingsType
        ): boolean => {
          debugmode({
            string: `Emitted ${type}`,
            obj: detail,
            force: settings?.debug,
          })
          const eventTarget = createOrGetCustomEventNode(hubId)
          return eventTarget.dispatchEvent(
            new CustomEvent('broadcast-' + type, { detail })
          )
        }
        return { on, once, off, emit }
      
        // Initiate or retreive node for custom event.
        function createOrGetCustomEventNode(hubId: string): Node {
          const nodeIterator = document.createNodeIterator(
            document.body,
            NodeFilter.SHOW_COMMENT
          )
          while (nodeIterator.nextNode()) {
            if (nodeIterator.referenceNode.nodeValue === hubId) {
              return nodeIterator.referenceNode
            }
          }
          return document.body.appendChild(document.createComment(hubId))
        }
      
        // Store each subscription (flag + details object serialized and hashed) in an array
        // taking advantage of the es6 modules intrinsic singleton properties.
        // If already stored reject request and exit silently.
        function handleCache() {
          const listenerExists = (
            type: string,
            listener: unknown,
            settings: settingsType
          ): { exists: boolean; id: string } => {
            const id = createBroadcastId(type, listener)
            debugmode({
              string: 'broadcastItemsCache',
              obj: broadcastItemsCache,
              force: settings.debug,
            })
            if (broadcastItemsCache.includes(type + id)) {
              debugmode({
                string: 'Prevented doublette subscriber.',
                force: settings.debug,
              })
              return { exists: true, id }
            }
            broadcastItemsCache.push(type + id)
            return { exists: false, id }
          }
          const remove = (type: string, listener: unknown) => {
            const removeId = createBroadcastId(type, listener)
            broadcastItemsCache = broadcastItemsCache.filter(id => id !== removeId)
          }
          return { listenerExists, remove }
        }
      
        // Serialize+hash the subscriber and store it to not add it twice.
        function createBroadcastId(flag: string, details: unknown): string {
          let detailsStringified
          switch (typeof details) {
            case 'function':
              detailsStringified = helpers().serializeFn(details as () => void, {})
              break
            default:
              try {
                detailsStringified = JSON.stringify(details)
              } catch (error) {
                throw new Error(
                  `Could not "JSON.stringify" the broadcasterjs payload of "${typeof details}" type.`
                )
              }
          }
          return helpers()
            .hashCode(flag + detailsStringified)
            .toString()
        }
      
        function setOptions(settings: settingsType): settingsType {
          const mergedOptions = { ...defaultSettings, ...settings }
          if (mergedOptions.debugGlobal) globalDebug = true
          return mergedOptions
        }
      
        function helpers() {
          const hashCode = (s: string) =>
            s.split('').reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0)
          const serializeFn = (f: () => void, env: unknown) =>
            JSON.stringify({ src: f.toString(), env: env })
          return { serializeFn, hashCode }
        }
      
        function debugmode({
          string,
          obj,
          force,
        }: {
          string: string
          obj?: unknown
          force?: boolean
        }) {
          if (!globalDebug && !force) return
          console.log(`%cBroadcast: ${string}`, 'color:#bada55', obj ? obj : '--')
        }
      }
      const broadcast = eventBus()
      export { broadcast }
    

(Even if BroadcasterJS is simple to use the source code for this site is public with plenty of examples that can be scrutinised here: https://github.com/nicatspark/broadcasterjs)

Debug
Psst! Go ahead, try inspect in devtools on this site now ->

So you implemented it and then things went south. Well, here is how to debug.

You can:

Inspect what listeners are active.Select elements tab in devtools. Find the <!-- broadcast-node -->. Select the node in dev-tools/elements tab and open event-listeners tab in second pane in dev-tools and all active listeners will be listed. Those starting with 'broadcast-' are yours.Screenshot of node in html that lists all element.
Activate a global debugmode that outputs all emit events as well as subscriptions when they occur to the console log.Add ?debug=broadcasterjs to your url and open the devtools console.
Activate debugmode locally that outputs a specific subscription/emit event to the console log.

Subscribe with debug locally:

broadcast.on(['EXAMPLE-FLAG', () => {
    setMyUseState(true)
  }], {debug: true})

Advanced use

Circumvent the doublette guard

BroadcasterJS is optimised for a SPA like situation where it can be a tall task to keep track of renders where subscription are set. Since the same custom event easily can be set multiple times BroadcasterJS by default does not allow more than one identical flag + callback combination to be set.

It is easy to circumevent this by sligthly changing the function to include a comment with a number /* 1 */ etc to ensure that the function is unique. You can also disable the guard by including a settings object as a third value in the subscriber array. The subscriber argument array would look something like ['MY-EXAMPLE-FLAG', myCallbackFnToRunOnEvent, {allowDoublettesSubscribers:true}]

Invoke the global debug

Aside from invoking the debug mode through the url with url params you can invoke it throught the settings object. Debug output will then start from when the settings object is set of course so the url method is preferable. Settings object example: ['MY-EXAMPLE-FLAG', myCallbackFnToRunOnEvent, {debugGlobal:true}]