Web widgets (Part 3): API Cookbook
Creating an API for a widget
August 31, 2021In the previous part of this series, you found out how to build a simple chat widget and load it onto a website in an elegant way.
Today you will learn how to communicate with the widget from the website on which the widget is embedded. By the way, I will smuggle you some knowledge about React contexts.
The project
Let’s start with designing API methods. We will make four of them:
- sendMessage(message) - “sends” a message from the website to the “server” via a widget
- show(), hide(), toggle() - control the visibility of the widget
We will use visibility control methods both on the website and inside the widget.
You may find it strange that a widget needs an API to hide itself. This is because we need to resize the iframe it is in. We can only do it from the outside, that is, from the page on which it is placed.
The principle of API operation
In order to use an API on the host page, we need to access it. How to do this if the widget is in an iframe? We will use a CustomEvent and postMessage for this, as we did earlier (in the second part of the series) when passing parameters to the widget.
We want the API to be elegant. We can’t tell the user to find an iframe with a selector and send some object to it with postMessage.
Documentation for such an API would be complicated and unreadable. Our goal is to make life easier for users.
So that that even a junior developer could use the widget’s API without going into complicated details of asynchronous communication.
I call it a one-button effect - a user gets the proverbial one button that provides full functionality, and the programmer took care of all the magic that lies behind pressing it.
I will definitely devote one of the following posts to the one-button effect.
The widget will accept commands sent by postMessage and perform certain tasks. It will also send commands that will be executed on the page.
The widget code runs in a separate space (in an iframe), so we can’t call its methods directly. Therefore, we need an intermediary. For this, we will use a loader, whose code runs in the user space i.e. on the hosting page.
Therefore, it is in the loader that we will create an object with API methods and make it available to the user.
But there is a trap here…
The loader script is loading asynchronously, and the user’s script can also load asynchronously. Therefore, the user needs to be able to check if an API is ready.
We will use CustomEvent and Promise for this. We will send an event to the loader from the user’s space asking if the API is ready. If the loader is loaded, it will catch the event and send back a response via the event as well. The API object will be passed in the response event. If the loader doesn’t respond, we will repeat the operation and so on until we get a response.
This way of passing the API guarantees that the user can obtain it anywhere in his code. Moreover, we don’t clutter user’s namespace with our code in an uncontrolled manner.
The whole thing will be wrapped in a neat library that will hide complicated code from the API user.
The user will get an object containing the API methods, and with the possibility of attaching callbacks in order to react to events coming from the widget and loader.
It seems complicated but it’s not. I think that the figure below will explain a lot (forgive me for not being a valid UML diagram).
I have shown four processes in the diagram:
- Green - getting the API object from the loader
- Brown - showing the widget triggered from the page
- Blue - sending a message from the page
- Pink (?) - hiding the widget triggered from... the widget
Enough talk, time to start programming.
Receiving messages in the widget
Let’s start by receiving messages in the widget.
Branch: stage-3-1
$ git checkout stage-3-1
The widget will support one API method called sendMessage. The other methods will be supported higher - on the loader level.
We already have a postMessage handler in the src/App.js file. We will expand it to handle new types of messages.
File src/App.jsOpen link in new window :
23if ( "greeting" in evt.data ) {24 setGreeting(evt.data.greeting);25} else if ( "sendMessage" in evt.data ) {
Now we need to implement message sending.
In a real application, we would attach sending to the server here e.g. using a WebSocket.
For the purpose of the tutorial, however, we limit ourselves to adding messages to the chat window.
All right, but how to add another message? We already have support for the greeting parameter but adding a greeting takes place only once - the first time the WidgetContainer component is rendered. We want to add a message every time it is received.
This can be done in two ways:
Method 1
In the WidgetContrainer component, we can expose the sendMessage method. Since it is a function component, you need to use the reference forwarding and the useImperativeHandle hook for this. This method is simple and will work. However, first, it’s not very elegant, and second, the React documentation advises against using imperative code whenever possible. Therefore, we will use the second way.
Method 2
This method uses React context mechanism. This is a bit more complicated but gives you wider possibilities.
This method will work much better in a real application, where the state will be much more extensive.
Let’s move on to the code.
We start with the provider’s context.
File src/ChatProvider.jsxOpen link in new window :
1import {createContext, useContext, useState } from "react";2
3const ChatContext = createContext();4
5export const ChatProvider = ({children}) => {6 7 const [messages, setMessages] = useState([]);8 9 const sendMessage = (message) => {10 setMessages(messages.concat(message));11 }12 13 return <ChatContext.Provider value={{14 messages,15 sendMessage,16 }}>{children}</ChatContext.Provider>17 18}19
20export const useChat = () => {21 const context = useContext(ChatContext);22
23 if (!context) {24 throw new Error("useChatContext must be within ChatProvider");25 }26
27 return context;28
29}
First, we created a context and a ChatProvider component that will provide it.
Then, in line 20, we defined a hook that returns messages and a method to “send” them.
A hook that will be used outside of ChatProviders will throw an exception. This is a protection against misuse of it.
As you see, the provider has now become a message store and has received a sendMessage method that ads a message to the array.
Until now, messages were stored in the array in the WidgetContainer component. Now everything will take place in the provider.
This approach has a huge advantage, because access to the hook, and thus to the methods and properties it provides is everywhere below the provider used.
So let’s add a provider to the application.
In the src/index.js file, we wrap the application in our provider:
File src/index.jsOpen link in new window
6import {ChatProvider} from "./ChatProvider";
10<ChatProvider>11 <App />12</ChatProvider>
Now, in the src/App.js file we have access to the sending method:
File src/App.jsOpen link in new window :
4import { useChat } from "./ChatProvider";
9const { sendMessage } = useChat();
25} else if ( "sendMessage" in evt.data ) {26 sendMessage({27 _id: nanoid(),28 message: evt.data.sendMessage,29 sender: "remote",30 direction: "outgoing",31 });32 }
Note that the useEffect hook, for receiving messages can be moved into the provider.
However, I leave this to you as homework :).
Since we have changed where messages are stored, we need to include this in the WidgetContainer as well.
In the src/WidgetContainer.jsx file, we replace the local messages array with the one coming from the hook:
File src/WidgetContainer.jsxOpen link in new window :
4import {useChat} from "./ChatProvider";
8const {messages, sendMessage} = useChat();
8const {messages, sendMessage} = useChat();
The greeting message can now be added using the sendMessage method:
11if ( greeting && messages.length === 0 ) {12 sendMessage({13 _id: nanoid(),14 message: greeting,15 sender: "remote",16 direction: "incoming",17 });18}
We can do the same with sending messages:
46sendMessage(newMessages);
Since we have made some modifications, you need to check if everything is still working.
Run the loader and widget and check the application. It should behave exactly the same as it did before the changes were made.
If everything is OK, then move on.
Providing the API object to the user
The next step is to provide the API object to the user. We move on to the loader.
Branch: stage-3-1
$ git checkout stage-3-1
Let’s start with the user library.
The library
The widgetApi.js file is the entry point for the user. It is a short script to get the API object from the loader.
File widgetApi.jsOpen link in new window :
1function widgetApi() {2 3 return new Promise((resolve) => {4 5 let timeoutId;6 7 const getApi = () => {8 const event = new Event('getWidgetApi');9 timeoutId = window.setTimeout(getApi, 1000);10 window.dispatchEvent(event);11 }12 13 const onWidgetApi = (e) => {14 const api = e.detail;15 window.clearTimeout(timeoutId);16 resolve(api);17 }18 19 window.addEventListener('widgetApi', onWidgetApi, { once: true });20 getApi();21 22 }); 23}
To simplify the tutorial, we define the widgetApi function in the global namespace. When creating a production version, of course, we can use a bundler that will allow the user to import this function.
The function returns a promise that will be resolved when the API object is received from the loader.
At the beginning, the getApi function is defined, which sends the getWidgetApi event to the window and immediately queues the next request after the specified time. It is this mechanism that handles the fact that the loader script loads asynchronously. When the event is sent for the first time, it could not be received because the loader does not exist yet.
Then, we assign an onWidgetApi callback to the window for the widgetApi event.
The callback retrieves the API object from the received event, cancels the next getApi execution, and resolves the promise by passing the object to the user.
If you follow the code carefully, you have probably noticed the shortcomings here.
What if the loader fails to load and respond? In this case, the getWidgetApi event will be sent over and over again.
I leave the handling of such a case to you. For more on this see the ”Do it yourself” section at the end of this post.
The library for the user is ready. Let’s move on to handling it in the loader.
We will start by providing an API object from the loader to the user space.
First, in the loader.js file, we create the API object.
File loader.jsOpen link in new window :
const api = { sendMessage: () => { }, show: () => { }, hide: () => { }, toggle: () => { } }
Then we prepare the handling of sending this object to the user using CustomEvent.
60window.addEventListener("getWidgetApi", () => {61 62 const event = new CustomEvent('widgetApi', {detail: api});63 window.dispatchEvent(event);64 65});
The matter is simple. We assigned a callback to the window object for the getWidgetApi event that responds with the widgetApi event.
In the event, we pass the API object, which will be received in the user’s library created before. You probably know that this method of providing data is called request-response.
Okay, but so far our API does nothing. We need to program its operation now.
Let’s go back to the sendMessage method of the API object:
39iframe.contentWindow.postMessage({40 sendMessage:message41}, "http://localhost:3000");
In the method body, we send a message to the frame. The message will be received in the widget and added to the message window.
Now you can check if your code is working.
User script
To check the API, we will prepare a simple script that uses our great library.
The script will send messages from the page to the widget when the button is pressed.
Let’s start with the text field.
File index.htmlOpen link in new window :
22<p>23 <textarea id="message" rows="5" cols="30"></textarea>24 <div>25 <button type="button" id="send">Send</button>26 </div> 27</p>
Now, the script.
File index.jsOpen link in new window :
1(function(){2 3 const content = document.getElementById("message");4 const send = document.getElementById("send");5 6 widgetApi().then( api => {7 8 send.addEventListener("click", () => {9 const value = content.value;10 if ( value.length > 0 ) {11 api.sendMessage(value);12 content.value = "";13 }14 });15 16 });17 18})();
The script is very simple. First, we get a reference to the textarea for typing messages and to the sending button.
Then, we run receiving the API. When it is received, we add an onclick callback to the button. The callback sends a message using the API and clears the content of the textarea.
We add the script to the page:
File index.htmlOpen link in new window :
28<script src="index.js"></script>
Now, we can check if it works. Run both projects and check if the message from the page appears in the chat pane. It should look something like this:
If it works it’s a good sign. We can proceed to the implementation of further API functions.
Visibility control
The visibility control functions will be performed by the loader.
Branch: stage-3-2
$ git checkout stage-3-2
File loader.jsOpen link in new window :
44show: () => {45 widget.style.display = "block";46},47
48hide: () => {49 widget.style.display = "none";50},51
52toggle: () => {53 const display = window.getComputedStyle(widget, null).display;54 widget.style.display = display === "none" ? "block" : "none"; 55}
Running of the show method will set the widget display property to “block”. The hide method sets the display to “none”, and the toggle switches the display depending on its current state.
Now, we will use the implemented methods.
In the index.html file, we add three buttons:
index.htmlOpen link in new window27<div>28 <button type="button" id="hide">Hide</button>29 <button type="button" id="show">Show</button>30 <button type="button" id="toggle">Toggle</button>31</div>
Then, in the index.js file, we add its handling.
We take references to the buttons:
File index.jsOpen link in new window :
5const hide = document.getElementById("hide");6const show = document.getElementById("show");7const toggle = document.getElementById("toggle");
And we add onclick events where API functions are called:
19hide.addEventListener("click", () => {20 api.hide();21});22
23show.addEventListener("click", () => {24 api.show();25});26
27toggle.addEventListener("click", () => {28 api.toggle();29});
Run both projects and check that the buttons are working properly.
If everything is OK, please rest for a while and move on to the final part of the tutorial.
How to hide a widget from… a widget
Controlling the visibility from the page is useful. But what if we want to hide the widget from within the widget code itself?
Contrary to appearances, it cannot be done just like that. The widget is loaded from a different domain than the page.
Therefore, we do not have direct access to the div in which this frame is placed from the widget frame.
However, we can use the loader and tell it to hide the widget.
How to do it? We will use the postMessage method again.
We implement receiving messages in the loader:
File loader.jsOpen link in new window :
68window.addEventListener("message", evt => {69 70 if( evt.origin !== "http://localhost:3000") {71 return;72 }73 74 if (evt.data === "hide") {75 api.hide();76 }77 78});
When the “hide” command comes from the widget window, we execute the api.hide() method. Simple!
Pay attention to checking that the message comes from the domain where you expect it to be. This is important for security reasons - we do not want to handle commands from a domain other than the widget’s domain in the API. It is similar the other way (see below).
Now, we need to send a command from the widget.
Branch: stage-3-2
$ git checkout stage-3-2
We add the “hide” method to the provider:
File src/ChatProvider.jsxOpen link in new window
13const hide = () => {14 window.parent.postMessage("hide", "*");15}
… and we add it to the object provided by the provider:
17return <ChatContext.Provider value={{18 messages,19 sendMessage,20 hide21}}>{children}</ChatContext.Provider>
Attention!
When sending a command from the widget, we need to pass the second targetOrigin parameter to the postMessage method.
Here for simplicity, we provide an asterisk (*). This means that the command can be caught by any parent page of the iframe.
Remember that depending on the data you are passing it can be dangerous. Therefore, whenever possible try to pass one selected origin.
Where to get it?
If the widget uses a license, you can ask your backend what origins are allowed for this license.
If you do not use the license, you can pass directly from the loader the origin of an actual page that has loaded the widget.
I wrote about how to do it in the second part of this series in the chapters ”Get the license” and
”Another way to pass parameters to the widget”.
Having information about the URL in the parent iframe you can check if it is allowed to pass commands to it.
If this is a publicly available widget and you do not send any sensitive data, you may not need to do so. Then you can stay with the asterisk.
Let’s move on to using the new method.
In the src/Widget.jsx file we make the following changes:
File src/Widget.jsxOpen link in new window :
We import the required elements:
2import { MainContainer, ChatContainer, ConversationHeader, MessageList, Message,3MessageInput, Button } from "@chatscope/chat-ui-kit-react";4import { useChat } from "./ChatProvider";
then, we get the hide method from the hook:
8const { hide } = useChat();
and finally we add a button that hides the widget:
14<ConversationHeader.Actions>15 <Button onClick={hide}>Hide</Button>16</ConversationHeader.Actions>
Great, it’s time to test.
After the project is launched, a button will appear in the widget’s header on the right. Pressing it will hide the widget.
You can show the widget again using the buttons on the page.
Is it working? So, we’re going on.
Reacting to events from the widget
We can now call the API methods from the page and from the widget. There is still work to be done to handle events emitted by the widget. Let’s get to work!
The example with the buttons has one disadvantage. Button states don’t change with the widget visibility change. If the widget is visible, the Show button should be disabled. Similarly, if the widget is hidden, the Hide button should be disabled. The Toggle button should always be enabled.
When we switch the visibility of the widget using the buttons on the page, the matter is obvious. But, what if we press the Hide button in the widget? This is where we can emit an event and attach a callback to it.
First, we will handle the button states when they are pressed on the page.
Branch: stage-3-3
$ git checkout stage-3-3
All the buttons are disabled by default - because there is no widget, there is nothing to control.
File index.htmlOpen link in new window :
28<button type="button" id="hide" disabled>Hide</button>29<button type="button" id="show" disabled>Show</button>30<button type="button" id="toggle" disabled>Toggle</button>
When the widget is loaded, we activate the Hide and Toggle buttons.
File index.jsOpen link in new window :
16hide.disabled = false;17toggle.disabled = false;
After pressing the Hide button, we deactivate it and activate the Show button. When pressing the Show button, we do the opposite. We can dress it up in a function that changes the button’s states to the opposite.
9const changeButtonsState = () => {10 show.disabled = !show.disabled;11 hide.disabled = !show.disabled;12}
We attach the function call to the buttons:
27hide.addEventListener("click", () => {28 changeButtonsState();29 api.hide();30});31
32show.addEventListener("click", () => {33 changeButtonsState();34 api.show();35});36
37toggle.addEventListener("click", () => {38 changeButtonsState();39 api.toggle();40});
Check if the states of the buttons are working correctly. If so, then we move on to the callback handling.
In the loader, we add a property to which an onHide callback will be assigned. By default, it is an empty function that does nothing.
File loader.jsOpen link in new window :
57onHide: () => {}
We run the callback after hiding which was initiated by the widget.
78api.onHide();
We go back to the page and add a callback:
File index.jsOpen link in new window :
42api.onHide = () => changeButtonsState();
Ready. Check if everything works by clicking a little on the buttons on the page and in the widget.
Delay
While testing, you may have noticed that the buttons are activated some time after the widget is loaded. This is due to the fact that we query the loader about the API object at every given interval. This causes that if you click the Hide button in the widget quickly enough, the buttons states on the page will be incorrect.
How to deal with this? It’s enough to add in the API the ability to get the current widget visibility, and when the API is ready, set the buttons on the page to the state got from the widget. I’m sure you can handle it without any problems, so I leave it for you to implement yourself.
Real communication protocol
We used a very simple protocol to transfer the data in the API. When sending data with postMessage, the command name is the field name of the object, when using CustomEvent, the command name is the name of the event.
This solution is ok when the API consists of a few commands. Sometimes, however, an application requires a more extensive API and asynchronous data transfer based on the request-response model.
It would be a pain to add eventHandlers to the window for each of the dozen or so commands. It would be equally difficult to manage the matching of responses to sent commands, handle timeouts, etc.
Then it is worth basing communication on a more structured protocol. For example, the JSON-RPC can be used.
It is such an extensive and interesting topic that I will definitely devote one of the following posts to it.
Do it yourself
Each software can be expanded infinitely. Surely, while reading this tutorial, you came up with a million ideas that can be improved or added to the API.
Below I present to you my ideas for improvement that can be used for learning and as inspiration for creating your own solutions.
- Moving the useEffect hook for receiving messages on onMessge, from the App.js file to the ChatProvider. Consider whether the greeting property of the WidgetContainer component is needed now
- To the widgetApi function add a parameter that configures send interval of the getWidgetApi event
- It can happen that the loader fails to load due to a server failure, for example. In this case, the widgetApi function will endlessly send the getWidgetApi event. We don’t want it, if only because of the increased energy consumption in mobile devices. Therefore, when sending the event, add the counter of the maximum number of attempts. After exceeding it, cancel the timeout and reject the promise. You can also add a parameter to configure the counter value.
- Write an API library for the widget encapsulating communication with the loader
- Add to the API the ability to get the widget state. When the API is ready, set the state of the buttons on the page to match the state of the widget
Summary
This was the last post in the series about web widgets. I hope that you have learned something new and that the acquired knowledge will encourage you to experiment further.
Let’s sum the knowledge acquired in the series.
In the first part you have learned:
- What is a web widget
- What functionalities can it provide
- Why place a web widget on your website
- Why place your widget on other websites
- Why is it worth writing your own widget, and what are the challenges associated with it
The second part was coding:
- You have written your widget
- You made the loader for it
- I have shown you how to pass attributes from the webpage to the widget
In part three I told you:
- What are the principles of API operation
- How to provide the API object to the user
- How to transfer data between the webpage, widget, and loader in both directions
- How to react on the webpage to events coming from the widget
Additionally, in the bonus post:
- I gave you some theory about why is worth placing widgets in an iframe
- You learned the story of failure in the project, which we made while making a chat widget
If you haven’t read the previous parts yet, I encourage you to read them. Below are links to the rest of the posts in the series, as well as a short bonus article.
Continue reading
Share
If you find this post valuable, please share it. Knowledge should be shared!