How to build an embeddable Javascript plugin

What we're building

This article is designed to walk you through building a javascript widget that can be embedded into third party websites.

Embeddedable plugins are used everywhere on the web - from Google analytics which track users, to Liveform where we use an embeddable plugin to show micro-surveys on websites.

While building the plugin that powers our micro surveys, we learnt a few lessons on how to engineer a robust javascript plugin - so to save you time, we've put together those lessons here.

Plugin architecture

The way we approached building the plugin was to split it up into 3 separate components:

  1. The embed code - This is what your end user will copy and paste into their website
  2. The "shim" - This is the code that is loaded by the embed code.
  3. The actual plugin code - This is your "app" hosted within an iframe, it will be what users interact with. In our case this was a React application which shows surveys.

Let's break down each component below.

Embed code

This is what your users will put onto their website. The plugin code should be short, and depending on your use case, provide an API that can be used to interact with your app.

In the case of our micro survey widget, the website is able to send information about the current user, or any events that the user has created.

Let's see how the embed code looks:

<script>
window.LiveFormRun = window.LiveFormRun || function(){(LiveFormRun.q=LiveFormRun.q||[]).push(arguments)};LiveFormRun.l=+new Date;
LiveFormRun('setProject', 'prj_1rYptzUFiog8gkvUGXIeiPwfDWW');
</script>
<script async src="https://sdk.liveform.co/js/shim.js"></script>

Let's unpack this.

The LiveFormRun method is what this plugin exposes to the website. It's an API for the website to interact with the plugin. It allows the website to call various methods using LiveFormRun('action', 'any-data-here')

At Liveform, accounts are organised by project, so we need to know what the current project is. We provide a setProject call, and the second parameter allows the website to send the project ID assoicated with their account.

Using this kind of method signature is flexible, and you can see this kind of scheme being employed in other plugins like Google Analytics.

Now you know the purpose of the embed code, let's dig a little deeper.

We'll first prettify the LiveFormRun method to see what's going on.

window.LiveFormRun =
  window.LiveFormRun || // Check if LiveFormRun is already defined, see (1)
  // If not defined, create our LiveFormRun function, see (2)
  function () {
    ;(LiveFormRun.q = LiveFormRun.q || []).push(arguments)
  }
LiveFormRun.l = +new Date()

The first part (1) will check to see if LiveFormRun is already defined. The LiveFormRun function will be provided by the shim when it completes loading. More on that later.

So while the shim is being fetched and loaded by the browser, we create a temporary function to immediately handle calls to LiveFormRun.

This is needed because there might be third party code on the website which calls the LiveFormRun function, so if the function isn't already defined, the calling code will throw an error. This is a Big No No! We cannot have our plugin cause errors on third party websites.

Let's unpack and prettify the temporary function for LiveFormRun that we define in the embed code:

function LF_Function() {
  // If the 'queue' isn't there, we create it
  if (!window.LiveFormRun.q) {
    window.LiveFormRun.q = []
  }

  // Then we push any arguments to this function into the queue
  window.LiveFormRun.q.push(arguments)
}

Our temporary function creates a queue LiveFormRun.q and pushes the arguments to the function into that queue.

This means that when our "real" LiveFormRun function is loaded by the shim, it can pull out queued calls and run them.

The last part of the embed code is the line which loads the shim, notice that we load it with the async attribute. This is so that we don't block other parts of the website when it is loading.

<script async src="https://sdk.liveform.co/js/shim.js"></script>

Hopefully that was a good overview of how to construct the embed code. The core purpose of the embed code is to load the larger shim asynchronously, and provide an API that the website can use.

Let's move onto the shim.

The Shim

The shim is the piece of Javascript code that is initially loaded by your embed code. It should be located on a CDN somewhere, and should be as small as possible.

The shim acts as a bridge between the embed code on the website, and your main application that runs inside an iframe.

For our feedback plugin, the shim creates a new iframe on the website and loads the React application that displays surveys within that iframe. The shim passes messages from the LiveFormRun function in the embed code to the iframe application using PostMessage.

Let's look at a minimal example of a shim:

declare global {
  interface Window {
    LiveFormRun: any
  }
}

let queuedCalls = []
let iframeReady = false
let iframe: HTMLIFrameElement | null = null

const initialize = async () => {
  // Create our iframe
  iframe = await createIFrame()
  iframeReady = true

  // Copy all pending commands into the shim's queue
  if (window.LiveFormRun.q) {
    queuedCalls = window.LiveFormRun.q.slice(0)

    // Run all pending commands
    callQueue(queuedCalls)
  }

  // Replace the temporary LiveFormRun function set by the embed code with the one
  // in this shim
  window.LiveFormRun = dispatcher
}

// Our actual function that handles calls to the LiveFormRun function on the website
const dispatcher = (name: string, args: any) => {
  call(name, args)
}

// Execute all commands that are pending in the queue
const callQueue = (queue: Array<[string, any]>) => {
  while (queue.length > 0) {
    const [name, args] = queue.shift()
    call(name, args)
  }
}

// This will use postMessage to send commands to your actual app
const call = (name: string, args) => {
  iframe!.contentWindow!.postMessage({ name, args }, '*')
}

// This create a new iframe within the third party website
const createIframe = (): Promise<HTMLIFrameElement> => {
  return new Promise((ready, reject) => {
    const iframe = document.createElement('iframe')

    iframe.onload = () => {
      resolve(iframe)
    }

    iframe.src = 'https://url-to-your-app.com/some/path.js'
    document.body.appendChild(iframe)
  })
}

initialize()

The first task of the shim is to load our javascript application using an iframe. Since our survey app is built in React, using an iframe makes the most sense here.

We want to isolate the app from the website - this means that any styles or javascript running on the third party website does not interfere with our app.

Once the iframe is loaded, the shim will look at the queued commands and push those messages to the iframe via the postMessage function.

Finally the shim will replace the temporary LiveFormRun function defined by the embed code, with the one in the shim (dispatcher). So from now on any calls to LiveFormRun will end up sending messages via postMessage directly to our iframe app.

The iframe application

The final piece of the puzzle is the application that runs within the iframe. When we built the survey plugin, we chose to use React, however you can use any framework that makes sense for your project.

Receiving messages from the shim is fairly straightforward in React, we can do the following:

const App = () => {
  const handleMessage = (event: MessageEvent<{ name: string; args: any }>) => {
    console.log(event.data)
  }

  React.useEffect(() => {
    window.addEventListener('message', handleMessage)
    return () => {
      window.removeEventListener('message', handleMessage)
    }
  }, [handleMessage])
}

So now whenever the website calls the LiveFormRun function, the handleMessage function will receive a message. Your application can now respond to events from the website!

Most of the work here has been building a communications channel between the embed code on the website, and the application running within the iframe.

Conclusion

Hopefully you have learned how to structure your plugin so that it can be embedded in third party websites. At Liveform we used a similar method to build our micro survey widget.