paint-brush
Customizing Dashboards: Building a Custom Extensions Engine for Your Appsby@andriiromasiun
358 reads
358 reads

Customizing Dashboards: Building a Custom Extensions Engine for Your Apps

by Andrii RomasiunMay 1st, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Building an engine to allow users to write custom extensions for any web application. Extensions are isolated JS modules that can access the dashboard data and various APIs. The engine is platform-agnostic and can be integrated into web app (in this article I used React). I also talked about extensions distribution process, writing an example extension itself and about this whole concept overall.
featured image - Customizing Dashboards: Building a Custom Extensions Engine for Your Apps
Andrii Romasiun HackerNoon profile picture


Nowadays, everyone uses at least one browser extension; they are crucial in personalizing the browsing experience and can be anything from adblockers to translation tools to accessibility add-ons.


With a web analytics service I developed, I wanted to add a similar functionality - let users customize their reporting dashboards, not with our pre-built components and buttons, but with something completely custom. The idea was to allow people to create extensions to enhance the dashboard in the way they wanted and even allow them to share their solutions with other users.


The implementation consists of 4 parts: building an extension engine to properly connect user-created extensions to our dashboard, integrating the engine into the dashboard itself, figuring out a way for users to share their add-ons (the marketplace), and the extensions themselves.

The engine

To enable custom extensions to communicate with the dashboard, I needed to build an extension engine. It would be responsible for running the extensions themselves and exchanging event data between them and the reporting dashboard.



I decided to use the OOP pattern for this because:

  1. It would allow me to load and initialize all extensions in the constructor.
  2. I would be able to easily hide private information from the extensions.
  3. It would also be convenient to share public, predefined functions with the extensions (like subscribing to dashboard events or sending some information to the dashboard).


export class Engine {
  private _sdkInitialised: boolean = false;
  private _emitQueue: Array<{ event: event; eventData: any }> = [];

  constructor(
    private extensions: Extension[],
    private options?: Options,
    private callbacks?: Callbacks
  ) {
    this._init();
  }

  private _init(): void {
    if (this._sdkInitialised || this.options?.disabled) {
      return;
    }

    const promisified = this.extensions.map(({ cdnURL, id }) =>
      this._loadExtension(cdnURL, id)
    );

    Promise.all(promisified).then(() => {
      this.debug("SDK initialised");
      this._sdkInitialised = true;
      this._emitQueue.forEach(({ event, eventData }) => {
        this._emitEvent(event, eventData);
      });
      this._emitQueue = [];
    });
  }

  private _loadExtension = (cdnURL: string, id: string): Promise<any> => {
    return (
      fetch(cdnURL)
        // Parse the response as text, return a dummy script if the response is not ok
        .then((res) => {
          if (!res.ok) {
            this.debug(
              `Error while loading extension from ${cdnURL}`,
              DebugType.ERROR
            );
            return "(() => {})";
          }

          return res.text();
        })
        // Execute the extension
        .then((code) => {
          eval(code)({
            ...this,

            // Presetting functions which require extension id
            addEventListener: this.addEventListener(id),
            removeEventListener: this.removeEventListener(id),

            // Functions that should not be exposed to the extensions
            _emitEvent: undefined,=
            _loadExtension: undefined,
            _init: undefined,
            _emitQueue: undefined,
          });
          this.debug(`Extension ${id} loaded and executed`);
        })
    );
  };
}


The constructor takes a list of extensions and initializes them asynchronously; this approach is good because if there's a large extension, it won't block the initialization of a smaller one. This approach also ensures the isolation of extensions so that none of them can access or expose each other's data.


It also creates an event emitter queue to guarantee that all events will reach the extension, regardless of how long it takes to initialize. For this reason, instead of using the browser's CustomEvent API, I chose to implement my own event emitters/listeners because I need to make sure that the messages are reliably delivered from the engine to the extensions and vice versa.


public _emitEvent(event: event, eventData: any): void {
  if (!this._sdkInitialised) {
    this._emitQueue.push({ event, eventData });
    return;
  }

  if (this.events[event]) {
    Object.values(this.events[event]).forEach((callback) => {
      setTimeout(() => {
        callback(eventData);
      }, 300);
    });
  }
}

Event emitter queue example.


The extensions themselves would be exposed to some of the methods in the engine class. For example, the engine has methods such as addEventListener or addPanelTab. These methods are built using the currying technique, which preserves the extension ID within the method context and only exposes the body of the method. Such an approach makes it really easy to add more methods as needed and scale the engine.


The extensions

As with browser extensions, our engine expects an input file (or module) to initialize it. A simple dummy extension would look like this:

(async (sdk) => {
  sdk.addEventListener('load', (data) => {
    console.log('Hey! The dashboard initialised:', data)
  })
})


The extension itself can be an asynchronous function called by the engine, allowing it to make network requests or perform other asynchronous operations as needed. The engine also passes the sdk object, which contains all the public methods available to an extension we defined earlier.


It's executed in the context of the dashboard and can access not only the information provided by the dashboard but also standard APIs, such as fetch or document and window objects to build custom functionality on top of it.


The dashboard

The extension engine itself is platform agnostic, meaning it can run in the context of any framework, such as Vue or the React library.


Our reporting dashboard is built using React, so the easiest way to integrate it is to use the useEffect hook. This hook is perfect for this use case because it allows us to initialize the extensions when the user navigates to the dashboard, as well as disable them when the user leaves the page by calling the destructor method.


useEffect(() => {
  if (_isEmpty(extensions)) {
    return
  }

  const sdk = new SwetrixSDK(
    extensions,
    {
      debug: isDevelopment,
    },
    {
      onRemoveExportDataRow: (label: any) => {
        setCustomExportTypes((prev) => _filter(prev, (row) => row.label !== label))
      },
      // your custom callbacks go here
    },
  )
  setSdkInstance(sdk)

  return sdk._destroy
}, [extensions])


The extensions list itself is user-specific. People can visit the extension marketplace and install or uninstall any extension at any time, but we will talk about the distribution system a little later.


I decided to initialize the extension engine in the useEffect hook and keep the instance as a state within the dashboard.
 This allows me to access this instance anywhere in the code of this dashboard to send events to the extensions at any time.


useEffect(() => {
  sdkInstance?._emitEvent('clientinfo', {
    language,
    theme,
  })
}, [sdkInstance, language, theme])


A simple example of sending events to extensions is to create a listener for the data of interest and send that data to the extensions in the context of some events.


Extensions distribution

After developing the extension system, the last problem I had was to find a way for people to share their extensions.


I developed a separate service called the "Marketplace.” People who had an account on our platform could simply log into the Marketplace and install the add-ons or create their own. If someone wanted to publish their own extension, they would have to go through a simple process of creating it on the Marketplace website and submitting the code for it. After our approval, the user's custom extension will be available for public use.


So when someone installs an extension, we map the user's ID to the extension's ID in our database and can later populate the engine with this data. The extension code is stored on a CDN, so all the engine has to do is grab the extension code by its ID and execute it.



Final thoughts

The current solution was designed with simplicity in mind and has proven to be scalable and reliable. It is currently delivering extensions to many users without any issues.


One possible improvement that could be made is to run the extensions using the Web Worker API. This would allow us to ensure that the extensions do not affect the performance of the page, as they would run in a separate background thread, but it would limit their access to the window and document objects, which may not be suitable for every application. All the code you’ve seen in this article is available here. Feel free to tinker with it :)


Overall, I believe that the add-on approach may even be used in the future not only for browsers or IDEs but also for regular web applications. It gives users more freedom in terms of the functionality they need and shifts the responsibility for certain features a bit from developers to end users.