Building a chat with Twilio, lit-html, Parcel and TypeScript was originally published on the Twilio Blog on May 14, 2018.
When building a web application you often reach a place where you’ll have to render a similar element multiple times and as efficiently as possible. That’s when a lot of web developers start reaching out to frameworks and libraries such as React, Vue or Angular. But what if we only want to do efficient templating and don’t want the tooling & code overhead of these libraries?
One library that aims to solve this problem is lit-html
by the Polymer team. It uses browser-native functionality such as <template/>
tags and tagged template literals to create efficient templating. Let’s take a look at how we can use it by building a Twilio Chat application with lit-html
.
Prerequisites
Before we get started make sure that you have Node.js and npm installed. Since we’ll be using Twilio Chat at one point in this application, you want to get a Twilio account as well. Sign up for free.
Project Setup
Start by creating a new folder for your project and initializing a Node project:
mkdir lit-twilio-chat
cd lit-twilio-chat
npm init -y
Before we get to the lit-html
part we need to configure two things. lit-html
comes as an npm
module that we’ll have to bundle into an application. We’ll be using zero configuration bundler called Parcel
for this but if you feel more comfortable with Webpack, you are welcome to use that one. We’ll also be using TypeScript for this project since both lit-html
and Twilio Chat have excellent support for TypeScript.
Install the following dependencies to get the two setup:
npm install -D parcel-bundler typescript
npm install -S tslib @types/events
Next create a file called tsconfig.json
in the root of your project and add the following:
{
"compilerOptions": {
"target": "es5",
"module": "ESNext",
"lib": ["dom", "es2015", "es2015.collection"],
"importHelpers": true,
"strict": true,
"moduleResolution": "node"
}
}
Then open the package.json
and modify the scripts
section to add a few new parcel
based commands:
"scripts": {
"build": "parcel build src/index.html",
"start": "parcel src/index.html",
"dev": "parcel watch src/index.html",
"test": "echo \"Error: no test specified\" && exit 1"
},
To test our setup, create an index.html
file as our entry file for Parcel inside a folder called src
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Chat with lit-html & TypeScript</title>
</head>
<body>
<div id="root"></div>
<script src="./index.ts"></script>
</body>
</html>
In there we load a file index.ts
via a relative path. Parcel will detect that file and will try to load it and start the bundling. Create the index.ts
file in the same directory as the index.html
file and place the following code into it:
const root = document.getElementById('root') as HTMLElement;
root.innerHTML = '<h1>Hello</h1>';
Test if the setup works by running:
npm start
This will start the parcel
bundler in watch mode to ensure the files are re-compiled whenever we change something. Open your browser and go to http://localhost:1234. You should have a greeting of “Hello”:
Time to Start Templating!
So far we are rendering something very static to the page without using lit-html
. Let’s dip our toes into lit-html
with a small task first. Let’s render the same content we previously had, plus the current time and update it every second.
Before we change the code, make sure to install lit-html
as a dependency:
npm install lit-html -S
We’ll need to import two functions from lit-html
for our code. One is html
and the other render
. html
is actually a tagged template function, meaning we’ll use it like this:
const templateResult = html`<h1>Hello</h1>`;
Tagged template functions work like normal template strings in JavaScript except that under the hood this will generate an HTML <template />
and make sure that re-rendering happens efficiently. This way we can use variables inside our templates but also nest templates. If you’re wondering how this is efficient, make sure to check out Justin Fagnani’s talk at the Chrome Dev Summit last year.
The render
function will then take care of actually rendering the content to the page.
Modify your index.ts
page accordingly:
import { html, render } from 'lit-html';
function renderTime(time: Date) {
return html`
<p>It\'s: ${time.toISOString()}</p>
`;
}
function renderHello(time: Date) {
return html`
<h1>Hello</h1>
${renderTime(time)}
`;
}
const root = document.getElementById('root') as HTMLElement;
setInterval(() => {
const view = renderHello(new Date());
render(view, root);
}, 1000);
If you still have the parcel dev server running from earlier, you should be able to switch back to http://localhost:1234 in your browser and see the page updated with a date constantly updating.
What About Components?
Breaking things into smaller components is always a great way to reuse code and make it more maintainable. We can do the same thing with lit-html
by creating functions that render different HTML structures and templates. Let’s create two components ChatMessage
and ChatMessageList
for our chat application. Create a new file src/ChatMessage.ts
:
import { html } from 'lit-html';
const ChatMessage = (author: string, body: string) => {
return html`
<div class="message">
<p class="message-author">${author}</p>
<p class="message-body">${body}</p>
</div>
`;
};
export default ChatMessage;
This is similar to stateless components in React.
Create another file src/ChatMessageList.ts
and place the following code into it:
import { html } from 'lit-html';
import { repeat } from 'lit-html/lib/repeat';
import ChatMessage from './ChatMessage';
interface Message {
sid: string;
author: string;
body: string;
}
const ChatMessageList = (messages: Message[]) => {
return html`
<div class="message-list">
${repeat(
messages,
msg => msg.sid,
msg => ChatMessage(msg.author, msg.body)
)}
</div>
`;
};
export default ChatMessageList;
We are using a helper function from lit-html
called repeat
that allows us to efficiently loop over an array to render each component. The function receives a list of items and two functions, one to identify a key to check which items updated and one to render the content. We are using msg.sid
for uniqueness as this will be a unique key for every message once we integrate Twilio Chat.
Let’s test our two components by rendering some mock messages. Alter src/index.ts
:
import { html, render } from 'lit-html';
import ChatMessageList from './ChatMessageList';
function renderMessages() {
const messages = [
{ sid: '1', author: 'Dominik', body: 'Hello' },
{ sid: '2', author: 'Panda', body: 'Ahoy' },
{ sid: '3', author: 'Dominik', body: 'how are you?' }
];
return html`
<h1>Messages:</h1>
${ChatMessageList(messages)}
`;
}
const root = document.getElementById('root') as HTMLElement;
const view = renderMessages();
render(view, root);
Switch back to http://localhost:1234 and you should see 3 messages being displayed:
Let’s Get Chatty
Now that we know how to render a list of messages, let’s add Twilio Chat to the mix to create an actual real-time chat. Twilio Chat provides a JavaScript/TypeScript SDK to build all the messaging logic, including channels and events. Install the SDK by running:
npm install -S twilio-chat
Let’s move the entire chat related logic into a central place that will handle rendering the interface as well as updating it when necessary.
Create a new file src/chat.ts
and place the following code into it:
import Client from 'twilio-chat';
import { Message } from 'twilio-chat/lib/Message';
import { Channel } from 'twilio-chat/lib/channel';
import { html, render as renderLit } from 'lit-html/lib/lit-extended';
import ChatMessageList from './ChatMessageList';
export class Chat {
private messages: Message[] = [];
private channel: Channel | null = null;
constructor(
private token: string,
private channelName: string,
private root: HTMLElement
) {
this.messageSubmit = this.messageSubmit.bind(this);
}
private messageSubmit(event: Event) {
}
}
We’ll be using lit-extended
in this case rather than the normal lit-html
to render the view. This enables us to use features such as event listeners.
In order to use the Twilio Chat SDK we’ll need to use a client token. We’ll take care generating this later but for now we’ll pass it in the constructor of the Chat
class and a channelName
of the channel we want to join and a root
HTML element that we want to render content into.
Next create a render
and messageSubmit
method. render()
will be responsible for rendering the entire UI from the list of messages to the input form. messageSubmit
will then be triggered whenever the form is submitted (i.e. a new message is sent). Add the following functions to your Chat
class:
constructor(
private token: string,
private channelName: string,
private root: HTMLElement
) {
this.messageSubmit = this.messageSubmit.bind(this);
}
private messageSubmit(event: Event) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const msg: string = form.message.value;
if (this.channel) {
this.channel.sendMessage(msg);
}
form.message.value = '';
}
private render() {
const view = html`
${ChatMessageList(this.messages)}
<form on-submit="${this.messageSubmit}">
<input type="text" name="message" placeholder="Type your message..." />
<button type="submit">Send</button>
</form>
`;
renderLit(view, this.root);
}
We need to establish a connection to Twilio Chat. For this we’ll have to:
- Create a Twilio Client
- Try to get the channel we want to join or create it if it doesn’t exist
- Join the channel
- Retrieve the existing messages
- Listen for new messages coming in
We’ll bundle all of that functionality into a public init()
function that kicks this off and a registerChannelListeners()
method that will be responsible for updating the UI if a new message comes in. Add the following code to your Chat
class:
private render() {
const view = html`
${ChatMessageList(this.messages)}
<form on-submit="${this.messageSubmit}">
<input type="text" name="message" placeholder="Type your message..." />
<button type="submit">Send</button>
</form>
`;
renderLit(view, this.root);
}
private registerChannelListeners() {
if (!this.channel) {
return;
}
this.channel.on('messageAdded', (msg: Message) => {
this.messages.push(msg);
this.render();
});
}
async init() {
const client = await Client.create(this.token);
try {
this.channel = await client.getChannelByUniqueName(this.channelName);
} catch {
this.channel = await client.createChannel({
uniqueName: this.channelName
});
}
if (this.channel.status !== 'joined') {
client.on('channelJoined', () => {
this.registerChannelListeners();
});
await this.channel.join();
} else {
this.registerChannelListeners();
}
this.messages = (await this.channel.getMessages()).items;
this.render();
return this;
}
}
Before we can test our code, we need to configure Twilio Chat. Log into the Twilio Console. Go to the Chat dashboard and create a new Chat service.
For every user we’ll have to generate a unique access token for Chat. For now we’ll just use a test token that you can generate inside the Chat Testing Tools. Type in an arbitrary “Client Identity” (think username) and generate the token.
Now switch back into src/index.ts
and replace the code with the following:
import { Chat } from './chat'
async function init() {
const root = document.getElementById('root') as HTMLElement;
const token = 'INSERT_YOUR_ACCESS_TOKEN';
const channel = 'demo';
const chat = new Chat(token, channel, root);
await chat.init();
}
init();
Make sure you replace INSERT_YOUR_ACCESS_TOKEN
with the token you generated. Go back to http://localhost:1234 and open the same page in a second window. You should be able to send messages in one window and they should show up in the other :)
Improving the Experience
Right now we have hard-coded the access token and that’s not ideal. You should instead generate this token dynamically server-side and either assign people different usernames or prompt them to create a username. We are also loading the entire Twilio Chat SDK on page load. What if the person got to the page but doesn’t actually want to chat?
Let’s modify our code to fetch a token dynamically and also make use of a concept called code-splitting by dynamically loading the chat related code if the user actually wants to chat.
To generate the access token we’ll be using Twilio Functions which allow you to host serverless functions written in Node.js directly on the Twilio infrastructure.
Go to the Functions part of the Twilio Console and create a new Blank function. Specify a path such as /chat-token
and place the following code into it:
exports.handler = function(context, event, callback) {
// make sure you enable ACCOUNT_SID and AUTH_TOKEN in Functions/Configuration
const ACCOUNT_SID = context.ACCOUNT_SID;
// you can set these values in Functions/Configuration or set them here
const SERVICE_SID = 'CHAT_SERVICE_SID';
const API_KEY = context.API_KEY || 'enter API Key';
const API_SECRET = context.API_SECRET || 'enter API Secret';
// REMINDER: This identity is only for prototyping purposes
const IDENTITY = event.username || 'only for testing';
const AccessToken = Twilio.jwt.AccessToken;
const IpMessagingGrant = AccessToken.IpMessagingGrant;
const chatGrant = new IpMessagingGrant({
serviceSid: SERVICE_SID
});
const accessToken = new AccessToken(
ACCOUNT_SID,
API_KEY,
API_SECRET
);
accessToken.addGrant(chatGrant);
accessToken.identity = IDENTITY;
const response = new Twilio.Response();
response.appendHeader('Access-Control-Allow-Origin', '*');
response.appendHeader('Access-Control-Allow-Methods', 'GET');
response.appendHeader('Access-Control-Allow-Headers', 'Content-Type');
response.appendHeader('Content-Type', 'application/json');
response.setBody({ token: accessToken.toJwt() });
callback(null, response);
}
Make sure to replace the value of SERVICE_SID
with the SID for your Chat Service.
Save the Twilio Function and copy the path for later use.
If you are using Twilio Functions for the first time, you’ll also want to go into the Configure section, enable ACCOUNT_SID
and add entries for an API_KEY
and API_SECRET
that you can generate a pair of them here.
Now that the token generation endpoint is ready, let’s start using it. First let’s update the src/index.html
file to add a new input field for a username and a button to start chatting:
<input type="text" id="username" placeholder="Username" />
<button id="btn">Start Chat</button>
<div id="root"></div>
<script src="./index.ts"></script>
Next we’ll modify the src/index.ts file
. Instead of immediately initializing everything, we’ll hold off until the button has been clicked. We’ll also use import('./chat');
as a dynamic import. This will tell parcel
that we only need everything from this module at a later part and parcel
will automatically perform code splitting for us. Update your code accordingly:
const TOKEN_URL = `YOUR_TOKEN_URL`;
const btn = document.querySelector('button') as HTMLButtonElement;
const input = document.querySelector('#username') as HTMLInputElement;
const root = document.querySelector('#root') as HTMLElement;
async function init() {
const username = input.value;
const tokenUrl = `${TOKEN_URL}?username=${username}`;
const resp = await fetch(tokenUrl);
if (!resp.ok) {
console.error('Failed to request token');
return;
}
const { token } = await resp.json();
const { Chat } = await import('./chat');
const channel = 'demo';
const chat = new Chat(token, channel, root);
await chat.init();
}
btn.addEventListener('click', init);
Switch back to your browser, open the developer tools and switch to the network pane. If you refresh the page now you should see a JavaScript file being loaded. When you then click on the “Start Chat” button, you should see multiple requests being made. One will fetch the access token from your Twilio Function and one will be the bundle that has all the code related to the chat functionality.
Your chat should work just like before, except that you can now have multiple users chat with different usernames. Give it a try by opening two windows at once.
Now Chat Away!
That’s it. We built a chat UI that is small and lightweight yet handles performant re-rendering thanks to lit-html
. We were also able to leverage code-splitting to further improve the page load time by only loading the things that are necessary on initial page load thanks to parcel
‘s out-of-the-box support for it.
If you want to deploy this to production, run:
npm run build
This will create a bunch of files in a folder called dist that you can serve from any static hosting service or upload to the Twilio Runtime Assets to serve.
From here you can go ahead and start styling your application or look into other functionality from Twilio Chat such as:
- Private channels
- Typing and reachability indicators
- Webhooks for bot responses
- The REST API to insert messages from your server
If you have any questions or if you want to show me something cool you built with Twilio or lit-html
, feel free to reach out to me:
- Twitter: @dkundel
- Email: dkundel@twilio.com
- GitHub: dkundel
Building a chat with Twilio, lit-html, Parcel and TypeScript was originally published on the Twilio Blog on May 14, 2018.
Top comments (0)