Hallo 😊

Kamsi Oleka, a software engineer, a gym rat, and a stand-up comedian amongst friends.

I have worked with interesting companies like Thepeer, Klarna, Piggy LLC, and Abeg.

A blog of my thoughts, experiences, and cool stuff I've built.

Back

Building an SDK part 1

I built three web SDKs for my company, and at the time, I didn't think it was an issue until someone reached out to me asking for my help. He wasn't sure how to build one, so I thought to share my knowledge on this. This article would serve as the stem for two follow-up articles, creating a React SDK and making a React Native SDK.

Prerequisite

  • Understanding JavaScript basics, JavaScript functions
  • Knowledge of Webpack

To build this SDK, we would be making use of the iframe. And it would be built in 2 parts, a vanilla JS end for the script and React app (this could be any framework of your choice) to serve as our iframe's source. To keep this as feasibly short as possible, I'd split this article into two. This article will focus on building the script, and the second on building the app.

What you'll learn

  • How to build an SDK
  • Possible security vulnerabilities and how to fix them

Step 1

Open up your terminal and make a directory called pay-script

mkdir sdk-script

and create two JavaScript files; utils.js and script.js

touch utils.js script.js

Step 2

Open up the script.js file, import utils.js and paste the functions below.

// script.js

const utils = require("./utils");
const origin = "http://localhost:3000";

function isRequired(key) {
  throw new Error(`${key} is required`);
}

function Pay({ onSuccess, onError, onClose, ...rest }) {
  if (!(this instanceof Pay))
    return new Pay({
      onSuccess,
      onError,
      onClose,
      ...rest,
    });
  Pay.prototype.config = rest;
  Pay.prototype.onClose = onClose || isRequired("onClose callback");
  Pay.prototype.onError = onError || isRequired("onError callback");
  Pay.prototype.onSuccess = onSuccess || isRequired("onSuccess callback");
  Pay.prototype.utils = utils();
}

First we check if prototype of our constructor exists in the prototype chain. If it doesn't, we call the constructor function with the new operator which in turn creates/assigns an empty object to this, adds properties to it and returns the value of this. It's important to have this check, else you could run into a situation where your methods aren't attached to prototype of your constructor and subsequent attempts to call them would throw a TypeError since they're undefined.

isRequired: function is a wrapper to throw an error for a missing prop.

Pay: is the constructor function to be exported by the script file. To share the props received across all instances of this function, and we'd assign them to the prototype object of Pay. That way, they can be referenced subsequently using this keyword.

NB:

this exists in every instance of non-arrow functions.

Step 3

Open the utils.js file, and we need to create some helper functions to manipulate the DOM.

Copy the code below; explanation after code block.

// utils.js
"use strict";

// the origin should point to your hosted app (React, Vue etc) in production,
//  but for development, it would point to localhost of your app (pay-app)
const origin = "http://localhost:3000";
const iFrameId = "pay-frame-id";
const containerId = "pay-widget-wrapper";

const utils = () => {
  function init({ title, config }) {
    if (
      document.getElementById(containerId) &&
      document.getElementById(iFrameId)
    ) {
      closeWidget();
    }

    const styleSheet = document.createElement("style");
    styleSheet.innerText = loaderStyles;
    document.head.appendChild(styleSheet);

    const loader = document.createElement("div");
    let childDiv = document.createElement("div");
    loader.setAttribute("id", "pay-app-loader");
    loader.classList.add("app-loader");
    childDiv.classList.add("app-loader__spinner");

    for (let i = 0; i < 12; i++) {
      let div = document.createElement("div");
      childDiv.appendChild(div);
    }
    loader.appendChild(childDiv);

    document.getElementById(containerId).appendChild(loader);

    const source = new URL(origin);
    const container = document.createElement("div");
    container.setAttribute("id", containerId);
    container.setAttribute("style", containerStyle);
    document.body.insertBefore(container, document.body.childNodes[0]);
    const iframe = document.createElement("IFRAME");

    const iframeAttr = [
      {
        key: "src",
        val: source.href,
      },
      {
        key: "style",
        val: iframeStyle,
      },
      {
        key: "id",
        val: iFrameId,
      },
      {
        key: "allowfullscreen",
        val: "true",
      },
      {
        key: "allowpaymentrequest",
        val: "true",
      },
      {
        key: "title",
        val: title,
      },
      {
        key: "sandbox",
        val: "allow-forms allow-scripts allow-same-origin allow-top-navigation-by-user-activation allow-popups",
      },
    ];

    iframeAttr.forEach(({ key, val }) => iframe.setAttribute(key, val));
    iframe.onload = function () {
      if (iframe.style.visibility === "visible") {
        const loader = document.getElementById("pay-app-loader");
        loader.style.display = "none";
      }
      iframe.contentWindow.postMessage(
        {
          type: "sdkData",
          config,
        },
        origin
      );
    };

    document.getElementById(containerId).appendChild(iframe);
    window.closePayFrame = closeWidget;
  }

  function closeWidget() {
    const container = document.getElementById(containerId);
    document.body.removeChild(container);
  }

  function openWidget() {
    const container = document.getElementById(containerId);
    const loader = document.getElementById("pay-app-loader");
    const frame = document.getElementById(iFrameId);
    container.style.visibility = "visible";
    container.style.display = "flex";
    loader.style.display = "block";

    setTimeout(() => {
      const container = document.getElementById(containerId);
      container.style.display = "flex";
      frame.style.display = "block";
      [container, frame].forEach((wrapper) => {
        wrapper.style.visibility = "visible";
        wrapper.focus({ preventScroll: false });
      });
    }, 1500);
  }

  return {
    openWidget,
    closeWidget,
    init,
  };
};

module.exports = utils;

const containerStyle =
  "position:fixed;overflow: hidden;display: none;justify-content: center;align-items: center;z-index: 999999999;height: 100%;width: 100%;color: transparent;background: rgba(0, 0, 0, 0.6);visibility:hidden;margin: 0;top:0;right:0;bottom:0;left:0;";
const iframeStyle =
  "position: fixed;display: none;overflow: hidden;z-index: 999999999;width: 100%;height: 100%;transition: opacity 0.3s ease 0s;visibility:hidden;margin: 0;top:0;right:0;bottom:0;left:0; border: none";
const loaderStyles = `.app-loader {
  text-align: center;
  color: white;
  margin-right: -30px;
  width: 100%;
  position: fixed;
  top: 30vh
}
@-webkit-keyframes app-loader__spinner {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}
.app-loader__spinner {
  position: relative;
  display: inline-block;
  width: fit-content;
}
.app-loader__spinner div {
  position: absolute;
  -webkit-animation: app-loader__spinner linear 1s infinite;
  animation: app-loader__spinner linear 1s infinite;
  background: white;
  width: 10px;
  height: 30px;
  border-radius: 40%;
  -webkit-transform-origin: 5px 65px;
  transform-origin: 5px 65px;
}
.app-loader__spinner div:nth-child(1) {
  -webkit-transform: rotate(0deg);
  transform: rotate(0deg);
  -webkit-animation-delay: -0.916666666666667s;
  animation-delay: -0.916666666666667s;
}
.app-loader__spinner div:nth-child(2) {
  -webkit-transform: rotate(30deg);
  transform: rotate(30deg);
  -webkit-animation-delay: -0.833333333333333s;
  animation-delay: -0.833333333333333s;
}
.app-loader__spinner div:nth-child(3) {
  -webkit-transform: rotate(60deg);
  transform: rotate(60deg);
  -webkit-animation-delay: -0.75s;
  animation-delay: -0.75s;
}
.app-loader__spinner div:nth-child(4) {
  -webkit-transform: rotate(90deg);
  transform: rotate(90deg);
  -webkit-animation-delay: -0.666666666666667s;
  animation-delay: -0.666666666666667s;
}
.app-loader__spinner div:nth-child(5) {
  -webkit-transform: rotate(120deg);
  transform: rotate(120deg);
  -webkit-animation-delay: -0.583333333333333s;
  animation-delay: -0.583333333333333s;
}
.app-loader__spinner div:nth-child(6) {
  -webkit-transform: rotate(150deg);
  transform: rotate(150deg);
  -webkit-animation-delay: -0.5s;
  animation-delay: -0.5s;
}
.app-loader__spinner div:nth-child(7) {
  -webkit-transform: rotate(180deg);
  transform: rotate(180deg);
  -webkit-animation-delay: -0.416666666666667s;
  animation-delay: -0.416666666666667s;
}
.app-loader__spinner div:nth-child(8) {
  -webkit-transform: rotate(210deg);
  transform: rotate(210deg);
  -webkit-animation-delay: -0.333333333333333s;
  animation-delay: -0.333333333333333s;
}
.app-loader__spinner div:nth-child(9) {
  -webkit-transform: rotate(240deg);
  transform: rotate(240deg);
  -webkit-animation-delay: -0.25s;
  animation-delay: -0.25s;
}
.app-loader__spinner div:nth-child(10) {
  -webkit-transform: rotate(270deg);
  transform: rotate(270deg);
  -webkit-animation-delay: -0.166666666666667s;
  animation-delay: -0.166666666666667s;
}
.app-loader__spinner div:nth-child(11) {
  -webkit-transform: rotate(300deg);
  transform: rotate(300deg);
  -webkit-animation-delay: -0.083333333333333s;
  animation-delay: -0.083333333333333s;
}
.app-loader__spinner div:nth-child(12) {
  -webkit-transform: rotate(330deg);
  transform: rotate(330deg);
  -webkit-animation-delay: 0s;
  animation-delay: 0s;
}
.app-loader__spinner {
  -webkit-transform: translate(-20px, -20px) scale(0.2) translate(20px, 20px);
  transform: translate(-20px, -20px) scale(0.2) translate(20px, 20px);
}
`;

1. init function

This function initializes the SDK. It first checks if the SDK is already open in the document and closes that instance. After which, it creates the loader for the iframe, the iframe's container, and the iframe itself and injects its source. The props passed into the SDK are communicated to the iframe's child (the source pointing to the hosted app).

There are 2 ways to pass the data to our source:

  • pass it in as a query and pick it from the source or;
  • dispatch an event to our source.

The latter is recommended for communicating between our script and the source on the Web. We can send it securely using the postMessage browser API.

2. turnOnVisibility function

This function toggles the visibility/display of the iframe's container and the iframe to display the SDK

3. turnOffVisibility function

This function toggles the visibility/display of the iframe's container and the iframe to hide the SDK

Step 4

Next, we need to declare more methods on Pay's prototype to handle some events.

// script.js

Pay.prototype.setup = function () {
  Pay.prototype.utils.addStyle();
  Pay.prototype.utils.init({
    title: "Pay SDK",
    config: this.config,
  });
};

Pay.prototype.open = function () {
  Pay.prototype.utils.openWidget({ config: this.config, sdkType: "send" });
  const handleEvents = (event) => {
    if (event.data.appOrigin !== origin) return;
    switch (event.data.type) {
      case "pay.success":
        Pay.prototype.success(event.data);
        break;
      case "pay.close":
        Pay.prototype.close(event.data);
        break;
      case "pay.server_error":
        Pay.prototype.error(event.data);
        break;
    }
  };

  Pay.prototype.eventHandler = handleEvents.bind(this);
  window.addEventListener("message", this.eventHandler, false);
};

Pay.prototype.close = function (data) {
  window.removeEventListener("message", this.eventHandler, false);
  Pay.prototype.utils.closeWidget();
  this.onClose(data);
};

Pay.prototype.success = function (data) {
  window.removeEventListener("message", this.eventHandler, false);
  Pay.prototype.utils.closeWidget();
  this.onSuccess(data);
};

Pay.prototype.error = function (event) {
  this.onError(event);
};

// This makes the module safe to import into an isomorphic code.
if (typeof window !== "undefined") {
  window.Pay = Pay; // make Pay available in the window object
}

module.exports = Pay;
  • setup

    We declared a setup method that would call the init function we created to create our iframe and inject the source (the app) into it.

  • open

    This method calls the openWidget function, which manipulates the styles of the created iframe to make it visible to a user. It simultaneously creates event handlers to handle dispatched events from the source.

    NB:

    It's important to note the first line in the handleEvents function. We check that the data received is actually from the expected origin (the [hosted app](https://www.ezemmuo.co/posts/building-a-web-sdk-part-2 that serves as our iframe's source)). This ensures we don't receive events from unknown sources and act on them.

    The function checks if the origins match and if not, does not with whatever event comes through.

  • close

    This method calls the closeWidget function, which manipulates the styles of the created iframe to hide it from your user. It removes the event listeners and calls the onClose callback passed to the SDK.

  • success

    This method performs similar functions as the close method, except it calls the onSuccess callback passed to the SDK.

  • open

    This method calls the onError function with the dispatch error from the source.

At the end of the script, we append our Pay constructor to the window object to make it available globally in whatever instance of the browser our script runs in.